Skip to content

Quota port: refund and settle, 429 with Retry-After and resets_at - #14

Merged
ParallelEntrepreneur merged 1 commit into
mainfrom
colonizer/issue-3-b0834a19
Oct 10, 2026
Merged

ParallelEntrepreneur merged 1 commit into
mainfrom
colonizer/issue-3-b0834a19

Conversation

@ParallelEntrepreneur

@ParallelEntrepreneur ParallelEntrepreneur commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Part of #3. Split: this PR carries only the open-core half of the billing work. Plans, the durable plan quota, Polar checkout, the Polar webhook and metering are hosted-only, so they moved to the private hosted repository (Ghostwritin/ghostwritin-cloud#1). That repository depends on this PR's head commit through an exact git pin. The branch was rebuilt on current main.

What changed (open)

Quota port (ghostwritin-core)

  • Quota::reserve returns a Reservation (account, words, period, resets_at).
  • Quota::refund(&Reservation) gives a failed rewrite's words back to the same window, never below zero.
  • Quota::settle(&Reservation) is new, with a default no-op. It runs after a successful rewrite so a hosted quota can bill what was used. It cannot fail, so it can never fail a finished rewrite.
  • Unlimited and DailyWordLimit implement both. DailyWordLimit now takes unix seconds and reports the next UTC midnight as its reset.
  • GhostwritinError::QuotaExceeded { resets_at, retry_after } is still 429 / quota-exceeded. BillingNotConfigured and BillingUnavailable are new (both 503).

API (ghostwritin-api)

  • handle() reserves before the model call, refunds if the engine fails (the original error is still returned), and settles on success.
  • problem() adds Retry-After and an RFC 3339 resets_at to 429s.
  • bearer() and problem() are now public, so a hosted module answers the same way.
  • New workspace dependency: time (RFC 3339 formatting; already in the graph through the harness).

README: the 429 fields and the refund on a failed rewrite.

Moved to the private repository

  • crates/ghostwritin-api/src/billing.rs: PlanQuota, BillingApi (checkout and webhook) and metering, which now goes through settle.
  • The Plan enum. Its allowances (500/day, 50k and 250k/month, My voice on Max) are product policy, so it is not in the open core, and Quota::plan() is gone with it.
  • The billing wiring in ghostwritin-worker/src/lib.rs (D1 DB binding, the Polar adapter, PlanQuota, mounting BillingApi). The open Worker is unchanged from main.
  • The billing tests from tests/routes.rs, the cratefield-adapter-polar and cratefield-adapter-sqlite dependencies, and the README "Plans and billing" section and Worker billing secrets.

Verification (local; Actions is billing-blocked)

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace: green, including the new a_failed_rewrite_refunds_and_a_good_one_settles and the 429 header test
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace
  • cargo build --target wasm32-unknown-unknown -p ghostwritin-worker

@ParallelEntrepreneur ParallelEntrepreneur changed the title Billing and word quotas through Polar: plans, durable quota with refund, API metering, 429 reset time Quota port: refund and settle, 429 with Retry-After and resets_at Oct 10, 2026
… and resets_at

The open half of the billing work. Plans, the durable plan quota, Polar
checkout, the webhook and metering are hosted-only and live in the
private Ghostwritin/ghostwritin-cloud repository.

- Quota::reserve returns a Reservation (account, words, period,
  resets_at). Quota::refund puts a failed rewrite's words back into the
  same window, never below zero. Quota::settle (default no-op) runs after
  a successful rewrite, so a hosted quota can meter what was used.
- Unlimited and DailyWordLimit implement both; DailyWordLimit takes unix
  seconds and reports the next UTC midnight as its reset.
- GhostwritinError::QuotaExceeded carries resets_at and retry_after; the
  API answers 429 with a Retry-After header and an RFC 3339 resets_at.
  New BillingNotConfigured and BillingUnavailable errors (503).
- The API reserves before the model call, refunds on failure, settles on
  success. bearer() and problem() are public so other modules answer the
  same way.

Co-authored-by: Colonizer Settlers <331648616+colonizer-settlers@users.noreply.github.com>
@ParallelEntrepreneur
ParallelEntrepreneur merged commit bb360d4 into main Oct 10, 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