Skip to content

feat(vultr): new @distilled.cloud/vultr SDK#291

Open
zeyuri wants to merge 7 commits into
alchemy-run:mainfrom
zeyuri:feat/vultr-sdk
Open

feat(vultr): new @distilled.cloud/vultr SDK#291
zeyuri wants to merge 7 commits into
alchemy-run:mainfrom
zeyuri:feat/vultr-sdk

Conversation

@zeyuri
Copy link
Copy Markdown

@zeyuri zeyuri commented May 12, 2026

Adds an Effect-native Vultr v2 SDK. 510 typed operations generated from the live Vultr OpenAPI 3.0 spec (mirror: zeyuri/openapi-vultr, 330 paths / 161 schemas, fetched from www.vultr.com/api/ on 2026-05-12). Full Compute + Managed Databases + IAM + OIDC + SCIM + CDN + Inference + Marketplace + VFS + Storage Gateways + Tickets surface.

import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import {
  CredentialsFromEnv,
  listPlans,
  listInstances,
  getInstance,
} from "@distilled.cloud/vultr";

const program = Effect.gen(function* () {
  const { plans = [] } = yield* listPlans({ type: "vc2" });
  const { instances = [] } = yield* listInstances({});
  return { planCount: plans.length, instanceCount: instances.length };
});

Effect.runPromise(
  program.pipe(
    Effect.provide(Layer.merge(CredentialsFromEnv, FetchHttpClient.layer)),
  ),
);

Verified live against the production Vultr API via packages/vultr/test/smoke.ts — credentials, query-string param encoding, response decoding, and typed error matching (404 → Errors.NotFound) all green.

Error modeling in the upstream spec

Asked by the maintainers — answer is "essentially zero":

0 component schemas named with Error / Failure / etc. (out of 161 total)
0 reusable responses in components.responses
1665 4xx/5xx response declarations across all operations
0 of them attach a content.application/json.schema — every single one is description-only:

    "404": { "description": "Not Found" }

The error envelope shape ({ error: string }) lives only in the spec's top-level prose intro, never as a Schema. The agent therefore hand-wrote the entire error matcher in src/client.ts against the prose shape and falls back to the standard status-code error classes from @distilled.cloud/sdk-core/errors (BadRequest, Unauthorized, Forbidden, NotFound, Conflict, TooManyRequests, etc.):

const ApiErrorResponse = Schema.Struct({
  error: Schema.String,
  status: Schema.optional(Schema.Number),
});

const matchError = (status, errorBody, _errors, headers) => {
  const parsed = Schema.decodeUnknownSync(ApiErrorResponse)(errorBody);
  const ErrorClass = HTTP_STATUS_MAP[status];
  return Effect.fail(new ErrorClass({
    message: parsed.error,
    retryAfter: parseRetryAfterForStatus(status, headers),
  }));
};

Practical implication: we get correct status-class errors on every operation, but no granular per-operation typed errors (InstanceLocked, PlanNotAvailableInRegion, QuotaExceeded, …) the way the Neon / PlanetScale SDKs do via JSON Patch. The patches/ directory is wired up — adding typed errors as we encounter them in tests is the planned follow-up.

Two generator bugs surfaced while testing

  • Name collisions: /regions and /vfs/regions both generated as listRegions; same with two pushzone endpoints colliding on getPushzone. src/operations/index.ts even has duplicate export * from "./listRegions.ts" lines. The OpenAPI generator's name derivation needs a path-based tiebreaker. 2 collisions across 510 operations — the 508 non-colliding ones all generate cleanly.
  • database.status not enum'd in the spec: documents values as Configuring | Rebuilding | Rebalancing | Running in the description, but types the field as plain string. Trivial patch but the kind of thing that causes a real downstream bug (a consumer of mine was comparing against lowercase "running" until I noticed the spec returns capitalized).

Notes / open items

  • Spec source is a community-style mirror (zeyuri/openapi-vultr). Vultr doesn't publish the OpenAPI at a stable URL — their docs page generates a blob: URL on demand. The mirror is a 1:1 copy from the docs site as of 2026-05-12. If you'd rather we live under alchemy-run/distilled-spec-vultr, I can move it; a daily fetch-specs.ts against the docs page would keep it fresh.
  • scripts/generate.ts has a pre-processing pass that camelCases hyphenated path parameter names ({baremetal-id}baremetalId) — the shared generator emits unquoted property keys, which is invalid for hyphenated names. Could probably lift into sdk-core/openapi/generate if other vendors hit it.
  • Downstream consumer ready: alchemy-run/alchemy-effect#307 builds Vultr.Compute.Instance and Vultr.Database.Postgres resources. Currently uses a hand-rolled HTTP wrapper since this SDK didn't exist yet; once this lands I'd port it to consume @distilled.cloud/vultr.

Reject as-is, scope it down, or ask for the spec source / generator collision fix first — happy to follow whichever direction makes review easiest.

zeyuri and others added 2 commits May 12, 2026 16:31
Generates 231 typed operations from the Vultr v2 OpenAPI spec
(community mirror at zeyuri/openapi-vultr — 155 paths, full Compute +
Managed Databases coverage). Follows the fly-io package layout:

- src/credentials.ts — VULTR_API_KEY env var, Context.Service + Redacted
- src/client.ts — Bearer auth, { error: string } body shape per Vultr docs
- src/errors.ts — standard 4xx/5xx classes from sdk-core + UnknownVultrError
- src/retry.ts — Effect 4 Context.Service shape (matches neon)
- scripts/generate.ts — preprocesses {hyphen-id} path params to camelCase
  before invoking the shared OpenAPI generator (the generator emits unquoted
  property keys, so {baremetal-id} would otherwise break)

bun run generate and bun run typecheck both pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The community spec from boywithkeyboard-archive/openapi-vultr was a
2-year-old snapshot. Replaced with the current spec downloaded from
https://www.vultr.com/api/.

Adds full coverage for: IAM (groups/policies/roles/trusts/sessions),
OIDC providers + JWKS, SCIM users/groups, CDNs (pull/push zones),
Inference, Marketplace, Tickets, Storage Gateways, VFS, Logs,
Bare-metal reverse-DNS, BGP setup, Block snapshots, API keys, NAT
gateways, Instance templates, Database connectors/quotas/topics/usage
+ kafka-connect/kafka-rest/schema-registry advanced options,
promote-read-replica.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@zeyuri zeyuri marked this pull request as ready for review May 12, 2026 19:49
zeyuri and others added 3 commits May 12, 2026 17:01
bun run packages/vultr/test/smoke.ts (with VULTR_API_KEY in env)
exercises credentials, HTTP wiring, response decoding, and typed error
matching against the production Vultr API. Read-only — costs nothing
to run. Covers listPlans, listInstances, listDatabasePlans,
listDatabases, and a 404 → NotFound error-class check via getInstance.
Generated by `bun scripts/generate-nuke.ts vultr`. Enumerates 17
resource categories (Instance, BareMetal, Database, KubernetesCluster,
LoadBalancer, ObjectStorage, VFS, StorageGateway, Inference, PullZone,
PushZone, Registry, NatGateway, BlockSnapshot, Block, Snapshot, Iso,
InstanceTemplate, ReservedIp, Vpc, Vpc2, FirewallGroup, DnsDomain,
SshKey, StartupScript). Excludes IAM/SCIM/OIDC/Tickets/Subaccounts by
design (account-admin scope).

Supports --dry-run and packages/vultr/nuke-config.json exclusion list.
Verified --dry-run reports 0 resources on a clean account, all 17 list
endpoints reached, typecheck clean.
Discovered via `bun scripts/error-discovery.ts vultr` probing the live
API across Compute / Database / BareMetal / Object Storage / CDN /
Load Balancer.

Two real bugs caught:

1. `client.ts:matchError` strict-decoded the error body as
   `{ error, status }` per the spec's prose. In practice many
   /databases/{databaseId}/* endpoints return `{ message }` with no
   status field and the strict decode fell through to
   UnknownVultrError, swallowing the typed class. Now accepts both
   shapes and uses the HTTP status as the source of truth for class
   mapping.

2. The spec only declared 422 on database write endpoints, but every
   /databases/{databaseId}/* op returns 422 for an invalid UUID, and
   POST /cdns/pull-zones returns 422 for invalid origin_domain.
   `patches/001-add-422-responses.patch.json` adds 39 JSON Patch
   entries. After `bun run generate`, 38 database ops + 1 CDN op now
   include `UnprocessableEntity` in their typed `errors` array.

Verified: typecheck, lint, format all clean. The `smoke.ts` live test
still passes — the bogus-UUID 404 probe still surfaces as the
NotFound class. No orphan resources left on the Vultr account.

No new error classes needed — every observed response across the
in-scope surface maps cleanly to the standard HTTP_STATUS_MAP from
sdk-core. Vultr's error bodies are plain `{ error|message: string }`
without any vendor-specific code structure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
inputSchema: GetDatabaseConnectorConfigurationSchemaInput,
outputSchema: GetDatabaseConnectorConfigurationSchemaOutput,
errors: [BadRequest, Forbidden, NotFound] as const,
errors: [BadRequest, Forbidden, NotFound, UnprocessableEntity] as const,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The main kinds of errors we care about:

  1. NotFound
  2. Unauthroized
  3. Throttling
  4. Conflict
  5. Transient errors (internal error)

We also use categories to tag errors as retryable so that a blanket retry policy can be applied across multiple calls (see core and cloudflare for examples of how this works with an openapi schema)

Copy link
Copy Markdown
Author

@zeyuri zeyuri May 12, 2026

Choose a reason for hiding this comment

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

Looked at this carefully — we're already aligned on the wiring. NotFound / Unauthorized / Conflict / BadRequest / UnprocessableEntity are typed per-op via patches/001-add-422-responses.patch.json and patches/002-add-missing-status-codes.patch.json.

TooManyRequests (Throttling) and the 5xx classes (Transient) intentionally aren't in per-op errors arrays — defaultErrorStatuses in packages/core/scripts/generate-openapi.ts:1153 strips them as cross-cutting:

const defaultErrorStatuses =
  config.defaultErrorStatuses ?? new Set(["401", "429", "500", "503"]);
// ...
for (const status of Object.keys(operation.responses)) {
  if (status.startsWith("2") || defaultErrorStatuses.has(status)) continue;
  // ↑ 401/429/5xx never get added to per-op `errors`

They surface at runtime through packages/vultr/src/client.ts matchErrorHTTP_STATUS_MAP[status], with categories pre-attached on the sdk-core base classes:

  • TooManyRequestswithThrottlingError + withRetryable({ throttling: true })
  • InternalServerError / BadGateway / ServiceUnavailable / GatewayTimeoutwithServerError + withRetryable()

So Vultr.Retry.throttling and Vultr.Retry.transient fire uniformly across the whole SDK without per-op type changes.

Just pushed 298422d with packages/vultr/test/retry.test.ts — 7 tests covering the category attachments and the retry policies as happy-path identities. Tried to also assert against a real live 429 by bursting 200 parallel listRegions, but Vultr's actual limiter is way looser than the documented 30 req/s and never fired — fell back to synthetic class checks.

zeyuri and others added 2 commits May 12, 2026 18:09
Pass-2 error-discovery covered Kubernetes, VFS, Storage Gateway, NAT
Gateway, Inference, Marketplace, Pull/Push CDN, Snapshot, BlockSnapshot,
Block Storage, ISO, Reserved IP, InstanceTemplate, Vpc/Vpc2, Firewall,
SshKey, StartupScript, BGP, Container Registry, Account/Billing reads,
BareMetal lifecycle, DNS.

Live probes found 4 operations missing 404 declarations:

  GET  /blocks/{blockId}                              had only 400/403
  GET  /blocks/snapshots/{snapshotId}                 had only 400/403
  POST /snapshots                                     had only 400 (instance_id 404)
  POST /object-storage/{objectStorageId}/bucket       had only 400/403 (parent 404)

patches/002-add-missing-status-codes.patch.json adds them. After regen
those four ops include NotFound in their typed errors array.

Vultr-side quirks documented (not patchable at spec level — see metadata):
  - Kubernetes ops return 500 instead of 404 for bogus cluster id
  - Storage Gateway mutations return 500 { error, error_type:'client' }
    with no `status` field
  - GET /reserved-ips/{id} for missing id returns HTTP 200 with embedded
    error envelope (surfaces as VultrParseError, not a status-class)
  - createPushzone silently accepts invalid `regions` as 201
  - /vpc2 endpoints now marked deprecated:true; stale pre-deprecation
    generated files still in src/operations/ — POST /vpc2 returns 403
    "VPC2 networks are deprecated"

All probe resources self-cleaned during the run (block, firewall group,
push zone, inference subscription — all 204 on DELETE). nuke --dry-run
post-run reports 0 resources. typecheck + smoke still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds packages/vultr/test/retry.test.ts. 7 tests, all green:

  - 5 synthetic: confirms TooManyRequests / InternalServerError /
    BadGateway / ServiceUnavailable / GatewayTimeout each carry the
    sdk-core category traits Vultr.Retry.throttling and
    Vultr.Retry.transient match against (ThrottlingError + retryable,
    ServerError + retryable respectively).

  - 2 live: a single listRegions wrapped in Retry.throttling /
    Retry.transient succeeds end-to-end. Verifies the policies are
    a stable identity on a happy-path call.

We tried to provoke a real 429 via a 200-parallel listRegions burst
to also test the retry-on-throttled path live, but Vultr's actual
rate limiter is looser than the documented 30 req/s and never fired.
Skipped that test path; the synthetic category attachment + the
matchError → HTTP_STATUS_MAP wiring give us equivalent coverage
without flakiness against an unpredictable upstream.

This addresses the maintainer's note re: NotFound / Unauthorized /
Throttling / Conflict / Transient being the priority error categories
and the category-tagged retry policy being the load-bearing piece.
NotFound/Unauthorized/Conflict are typed per-op via patches/001 + 002;
Throttling/Transient flow through matchError at the client level (by
generator design — defaultErrorStatuses in scripts/generate-openapi.ts
excludes 401/429/5xx from per-op `errors` arrays because they're
cross-cutting), with the categories on those classes powering
Retry.throttling / Retry.transient as verified here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines +87 to +91
export const listDatabases = /*@__PURE__*/ /*#__PURE__*/ API.make(() => ({
inputSchema: ListDatabasesInput,
outputSchema: ListDatabasesOutput,
errors: [BadRequest, Forbidden, NotFound] as const,
}));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This should be a paginated API. Take a look at core, cloudflare, and shared open api generator to see how we do this kind of thing. Need to identify cursor/next token/etc.

distilled paginated APIs replace manual pagination with an Effect Stream

listDatabases.pages({..}).pipe(Stream.map(..))
listDatabases.items({..}).pipe(Stream.map(..))

"items" vs "pages" are API specific.

@Mkassabov
Copy link
Copy Markdown
Contributor

Mkassabov commented May 16, 2026

@zeyuri

From our experience ai isn't great at generating distilled SDKs when operating outside of the harness we have for it (create-sdk-full script). One example is that test coverage is not per operation and the pr comment implies that error discovery wasn't run meaning there could be a lot of missing errors.

Can we please redo this with the create-sdk-full script we have in the repo; manually repairing it is a really deep rabbit hole.

@zeyuri
Copy link
Copy Markdown
Author

zeyuri commented May 16, 2026

@zeyuri

From our experience ai isn't great at generating distilled SDKs when operating outside of the harness we have for it (create-sdk-full script). One example is that test coverage is not per operation and the pr comment implies that error discovery wasn't run meaning there could be a lot of missing errors.

Can we please redo this with the create-sdk-full script we have in the repo; manually repairing it is a really deep rabbit hole.

will do

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants