Skip to content

feat: write safety, error classes, and the Features resource - #10

Merged
gastonrey merged 4 commits into
mainfrom
feat/write-safety-and-features
Aug 21, 2026
Merged

feat: write safety, error classes, and the Features resource#10
gastonrey merged 4 commits into
mainfrom
feat/write-safety-and-features

Conversation

@gastonrey

Copy link
Copy Markdown
Collaborator

Write safety for the endpoints a seat writer is about to depend on, plus the Features
routes the catalog push currently reaches through send(:request, …).

Every route, envelope and status below was verified against the sandbox, not inferred.
That mattered: one of the shapes this gem already documented returns 400.

The idempotency question, settled

POST /v1/subscriptions/{id}/update, same body, twice:

result
same Idempotency-Key same operation id both times — replays
same X-Idempotency-Key two different ids — header ignored
no key at all two different ids

So the standard header is what makes a retried write safe, and nothing else is: the endpoint
is not idempotent on its own. update_count sets the count outright rather than incrementing,
so the count survives a double apply — but two operation records do not.

The retry therefore engages only when a caller supplies a key, on 429 and 5xx, bounded at
two attempts. A 409 or a 422 is the API's answer and is raised at once. Faraday's own retry is
left exactly where it was, on the idempotent verbs.

Changes

  • 409 → ConflictError, 422 → UnprocessableEntityError. Both fell through to the generic
    ApiError, so "this already exists" and "well formed but not allowed" were indistinguishable
    from an unrecognised status. The catalog push already depends on telling them apart by
    rescuing ApiError and re-raising on status.
  • idempotency_key: on every mutating method, with the bounded retry described above. The
    transport moved to a Requests module — same methods, extracted so BaseResource stays
    inside its length budget with the retry added.
  • Features resource. Keyed by code, not an opaque id, so get/update/delete take a code.
    /v1/features uses the standard meta/data envelope, so list and Collection work unchanged.
  • Products#archive, #features, #link_feature, #unlink_feature — the four routes the
    catalog push reaches through client.products.send(:request, …) today.

Contracts discovered along the way

  • A feature must be archived before it can be deleted. DELETE on an active one answers
    400 Cannot delete a feature that is not archived, so removal is archive-then-delete. Hence
    Features#archive.
  • Archiving is a PUT for both products and features; POST answers 404 "Route not found".
  • GET /v1/products/{id}/features answers with a bare array of feature_code/value — no
    envelope, nothing to paginate, no Collection to build.
  • update_operation's documented payload was wrong. It omitted payment_schedule, which
    the endpoint requires, so the body in the comment and in the spec returns 400. Nothing caught
    it because the spec stubs the request and the stub matched the same wrong body. The verified
    shape, with the enum values, is now recorded on the method.

Verification

  • 105 examples, up from 75. RuboCop clean across 34 files.
  • Every guard mutation-tested — seven mutations, seven caught: retry without a key, retry on any
    ApiError (so a 409 would be retried), never sending the header, dropping 409/422 from the
    mapper, archive as POST on products and on features, and link_feature sending no value.
  • End to end against the sandbox with this branch loaded: create a feature, get it, update it,
    link it to a real product, unlink it, archive it, delete it — with the product's linked-feature
    count restored to exactly the 39 it started with, and the probe feature returning 404 at the
    end. Nothing left behind.
  • One caller had to change: PriceBooks#add_products passed its body as bare keywords, which
    the new keyword would have swallowed. Caught by the existing suite, now passes a hash.

Not here

No version bump and no CHANGELOG entry, following the convention of the previous feature PR
(#9, Customers) where both were left to a dedicated release commit.

The backend cannot use any of this until this merges and the monorepo's Gemfile pin moves —
it currently reads github: 'factorialco/hyperline-cli', branch: 'main'.

Both fell through to the generic ApiError, so "this already exists" and "this
payload is well formed but not allowed" arrived indistinguishable from an
unrecognised status. The catalog push already depends on telling them apart --
it rescues ApiError and re-raises unless the status is 409 or 422 -- which is
the shape a caller is forced into when the class does not carry the meaning.

ConflictError is the one a caller often wants to treat as success.
UnprocessableEntityError is the one that never becomes success by retrying.
Verified against the sandbox: POST /v1/subscriptions/{id}/update with the same
body and the same `Idempotency-Key` returns the same operation id both times,
while the same body with no key creates two operations. `X-Idempotency-Key` is
ignored. So the header is what makes a retried write safe, and nothing else
does -- the endpoint is not idempotent on its own.

The retry therefore engages only when a caller supplies a key, on 429 and 5xx,
bounded at two attempts. A conflict or a validation failure is the API's answer
and is raised at once. Faraday's own retry is left on the idempotent verbs where
it already was.

Every mutating method takes an optional idempotency_key. PriceBooks#add_products
passed its body as bare keywords, which the new keyword would have swallowed; it
now passes a hash. The transport moved to a Requests module -- the same methods,
extracted so the class stays inside its length budget with the retry added.
The example for #update_operation omitted payment_schedule, which the endpoint
requires -- so the documented body returns 400. Nothing caught it because the
spec stubs the request, and the stub matched the same wrong body the comment
described.

Recorded from the sandbox: payment_schedule is immediately / next_invoice /
custom, calculation_method is pro_rata / pay_in_full / do_not_charge, and the
operation types are update_count, update_prices, add_coupon and remove_coupon.
The response is the created operation, { "id" => "supd_..." }.
The catalog push reached all of this through `client.products.send(:request, …)`,
which works and should not be what a writer depends on. Every route and envelope
below was verified against the sandbox rather than inferred:

- Features are keyed by `code`, not an opaque id, so get/update/delete take a
  code. /v1/features answers with the standard meta/data envelope, so list and
  Collection work unchanged.
- Products#features answers with a **bare array** of feature_code/value, no
  envelope -- nothing to paginate and no Collection to build.
- Archiving is a PUT for both products and features; POST answers 404 "Route not
  found". For a feature it is also a prerequisite for deletion: DELETE on an
  active one answers 400 "Cannot delete a feature that is not archived".
- link_feature carries `{ value: }` -- true for a boolean feature, the cap for a
  numeric one -- and 400 means the value_type cannot hold it.

Verified end to end against the sandbox: create, get, update, link to a product,
unlink, archive, delete, with the product's linked-feature count restored to
exactly what it was.
@gastonrey
gastonrey marked this pull request as ready for review August 21, 2026 13:49
@gastonrey
gastonrey merged commit 080a164 into main Aug 21, 2026
1 check passed
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.

1 participant