FAQ

For anyone who has looked at the API and thought "why on earth is it like that?". Each answer is the honest one, and where a decision was recorded at the time, the architecture decision record it links to carries the full reasoning.

Why Future and not cats-effect, ZIO, or a direct-style effect?

Because a client library should not choose your effect system for you.

A user on ZIO should not inherit cats-effect. A user on cats-effect should not inherit ZIO. A user on neither should inherit nothing at all. Future is in the standard library, so it is the one type that costs every reader exactly the same — nothing.

That is not a claim that Future is a good effect type. It is eager, it needs an ExecutionContext threaded through everything, its cancellation story is poor, and its failure channel is untyped. The typed rail (.attempt, returning Either[CodebergError, A]) exists specifically because the last of those is a real cost, and it hands the failure back as a value for anyone who wants one.

Wrapping a Future-returning API in your own effect type is one line (IO.fromFuture, ZIO.fromFuture, Future.await under Ox). Unwrapping somebody else's chosen effect is not.

ADR-0005 records the decision, and notes that it settled a genuine disagreement between two of this repository's own planning documents. ADR-0002 records the internal consequence: the cross-cutting logic is written against a forty-line Exec[F] typeclass with two instances — Future for the published client, and Either for the tests — so the retry engine and the pagination driver are tested synchronously, without Await and without timeouts, and the published artifact carries no effect-system dependency at all.

Why opaque types instead of String?

Because a String that reaches a request path unchecked is a way to address an endpoint the API never offered.

Owner, RepoName, Username, OrgName, BranchName, RefName, ContentPath and the rest are all validated as URI path segments. A value containing /, a control character, or a .. segment is rejected at construction. That is a security boundary rather than tidiness: client.users.keys on "someone/../../admin" would otherwise compile and dispatch.

There is a second reason, and in day-to-day use it matters more. Three of those types — Owner, Username, OrgName — are the same characters on the wire and answer three different questions: "who owns this repository?", "which person is this?", "which organisation is this?". One shared type would let client.organizations.members(repository.slug.owner) compile against a personal account that has no members. Converting between them is a deliberate step through from, never an implicit widening.

The cost at run time is zero. An opaque type Owner = String is a String once the program runs — no wrapper object, no allocation. The cost at the call site is one Either to handle, once, at the boundary where the string came from.

Why do smart constructors return Either rather than throwing?

Because a bad owner name is not exceptional. It is a value your program received from a configuration file, a command line, or a user, and handling it is ordinary control flow.

Returning Either also lets several checks compose in one for comprehension that stops at the first failure, which is what you almost always want when building a request out of three or four validated parts.

Why two error rails? Is that not two ways to do the same thing?

It is one implementation exposed twice, and the duplication is mechanical: .attempt is the convenience rail with its failure channel materialised by a single combinator. They cannot drift, because one is defined in terms of the other.

They exist because two kinds of caller genuinely disagree. A codebase written around Future wants failures on Future's failure channel, where recover works and a for comprehension does not have to unpack an Either inside a Future at every step. A codebase that models errors as values does not want a second, exception-shaped channel next to the one it already has.

CodebergException carries the full CodebergError, so the convenience rail loses nothing. Pick per call site: the typed rail where you are deciding something, the convenience rail in plumbing that forwards results.

Why is there no listAll on each group?

There is a walk — com.worxbend.codeberg4s.paging.PageWalk — but it is one helper rather than a listAll on each of the thirty-eight API classes, and that is deliberate.

PageWalk.all(PageParams.First): params =>
  client.issues.list(owner, name, IssueQuery.Empty, params)

The termination rule is the subtle part of pagination: it reads rel="next" from the Link header and must never compare items returned against items requested, because Forgejo clamps limit to 50 while echoing back what you asked for. One listAll per group would be one copy of that rule per group, and copies drift.

Because the walk takes the operation as an argument, it works on every listing in the library — including any added later — without those listings knowing it exists. PageWalk.fold and PageWalk.foreach are the bounded-memory forms; see Pagination.

Why Page at all? Why not return a List?

Because a repository can hold tens of thousands of issues, and an operation that quietly fetches all of them is an operation that quietly turns one listing into an outage.

More importantly, a List would have to be assembled by a loop, and the obvious loop against Forgejo is wrong. Forgejo clamps limit to the instance's maximum while echoing the value you asked for, so items.size < requested is true on every page and a loop written that way silently returns a truncated result. Page makes the correct signal — the presence of rel="next" — the thing you reach for. See Pagination.

Why jsoniter-scala rather than circe or jsoniter?

Three reasons, in the order they mattered.

sttp client4 ships a first-party jsoniter-scala integration module, so the response path is one dependency rather than two plus glue. The transitive footprint is small, which matters for a published library for the same reason as the effect-system decision. And this project writes explicit codecs anyway — every field is hand-checked against a captured response — so circe's derivation conveniences buy less here than they usually would.

The costs are real and recorded: snake-case wire fields need explicit mapping, and a top-level JSON array needs a codec for the collection as well as for the element — a mistake that compiles and fails at run time, which is why every list endpoint has a decode test against a captured fixture.

ADR-0003.

Why hand-written models instead of generating them from the OpenAPI document?

Because generating from this document produces code that is wrong in ways that only surface in your application.

The specification is Swagger 2.0, which has no nullable. Measured over the pinned document: of 246 definitions, zero response models declare a required list, and the string nullable does not appear anywhere. Read literally, it asserts that every field of every response is optional and none may be null — which is simultaneously useless and false. A live Issue from codeberg.org returns JSON null for five fields the document declares as typed values, including one it declares as an array. A generator produces a decoder that crashes on the first issue of the first page.

It also cannot express the union at GET /repos/{owner}/{repo}/contents/{filepath}, which returns an object for a file and an array for a directory: the union exists only in the English summary, and the machine-readable schema claims the response is always an object.

And generated names are permanent API design. repoGetContents and issueGetCommentsAndTimeline are the specification's internal naming, and once published they are yours forever.

So the models are hand-written and validated against golden fixtures captured from the live API. Where the specification and a fixture disagree, the fixture wins. The specification is still vendored and checksummed — as a reference and a drift detector, not as a source of code. ADR-0001, and docs/HAZARDS.md for the measurements.

Why is the retry engine written here rather than using a retry library?

com.softwaremill:retry was evaluated first, and two things disqualified it.

It is Future-native, and the retry driver here has to run over the abstract Exec[F] so that it can be unit-tested with F = Either — synchronously, with a fake timer that records requested sleeps rather than sleeping. A Future-only library cannot sit behind that seam. And it brings odelay for scheduling, which adds a timer thread and a transitive dependency to a library whose whole dependency argument is minimalism.

The cost is a few dozen lines this project owns. Because retry sits behind a port, replacing it later with a third-party library would be a non-breaking change. ADR-0004.

Does it work against Gitea?

Very likely much of it, and that is a plausible expectation rather than a tested claim.

Forgejo forked from Gitea and the v1 API is largely shared — codeberg.org's own version string is 16.0.0-dev-668-1bdb1938+gitea-1.22.0. But nothing in this repository runs against Gitea: the container integration suite starts codeberg.org/forgejo/forgejo:12, and the live smoke suite talks to codeberg.org. Endpoints Forgejo added or renamed after the fork will not exist, and the models are built from Forgejo captures.

If you try it, client.misc.nodeInfo() reports which software you actually reached and client.version.get() reports its version. If you find divergences, they would be worth reporting.

Why is admin not covered?

admin, activitypub and package are out of scope for v1 — a deliberate scoping decision, not an oversight. Everything else in the Forgejo v1 API is implemented on both rails. docs/API_INVENTORY.md has the endpoint-level checklist.

Note that client.repos.admin is unrelated to that tag: it is this library's own grouping for administering a repository you own, and it needs no instance-admin privileges.

Why can I not stream a large file or artifact?

Because this library does not stream, anywhere. client.downloads.artifact and client.downloads.runLogs hold the whole archive in memory, and so do the raw and media file endpoints.

That is a real limitation to plan around rather than a setting to change. If you need to stream a large blob, use the download_url a content entry carries and fetch it with your own HTTP client.

Why does the library not log anything?

Because a published library that drags SLF4J, Logback and a configuration file behind it is a nuisance to embed: it collides with whatever the application already uses, and it emits output nobody asked for.

Implement Telemetry[Future] and pass it at construction. That is one small trait between your logging and this code, and the callbacks receive only already-redacted values, so an implementation cannot leak a credential by logging what it is handed. See Observability.

Why does it not surface rate-limit headers?

Because they are not there in any dependable form.

Codeberg emits draft-IETF ratelimit and ratelimit-policy headers — not the GitHub-style X-RateLimit-* — and those come from Codeberg's edge rather than from Forgejo, so a self-hosted instance will very likely emit neither family. Building a RateLimit type on top of that would be a feature that works on exactly one deployment.

The current release therefore reports rate limiting as a 429 and nothing more. If you need the counters, own the sttp backend and read the headers there. See Retries and rate limits.

Why is PageSize capped at 50 when my instance allows more?

Because 50 is Codeberg's max_response_items, and asking for more there is silently clamped rather than refused — the failure mode this library exists to prevent.

On a self-hosted instance configured with a larger ceiling, the cap is a genuine limitation of the current release: you cannot ask for pages bigger than 50. On one configured below 50 it is not enough on its own, which is why client.misc.apiSettings().map(_.maxResponseItems) is where the real ceiling lives. See Self-hosted instances.

Why five artifacts instead of one?

Because the module graph is what enforces the architecture, and publishing it is what keeps it honest: domain has no dependencies at all, core never imports sttp or jsoniter-scala, codec never imports sttp.

You almost certainly want codeberg4s-client, which pulls in the other four transitively. The exception is a module that needs to handle a CodebergError without making requests — a shared error-rendering module, say. That can depend on codeberg4s-domain and link no HTTP client at all.

Why is nothing on Maven Central?

Because 0.1.0 has not been tagged. The build is configured for it and the coordinates are stable; until then they resolve only against a local publish. This is the first thing a stranger hits, and it is tracked as blocking in docs/READINESS.md.

Is it thread-safe? Can I share one client?

Yes, and you should. A CodebergClient is immutable apart from its closed flag and is meant to be shared: build one per instance you talk to, for the lifetime of the application.

Building one per request creates a scheduler thread each time, and — on the CodebergClient(config) path — a connection pool as well.

close() is idempotent and safe from any thread. It releases the scheduler thread, and the HTTP backend as well when the client created it; a backend you passed to usingBackend is yours to close.

What Scala versions are supported?

Scala 3 only, built against 3.8.4. There is no Scala 2 cross-build and no Scala.js or Scala Native build; the last two are explicitly deferred.

The API uses Scala 3 features throughout — opaque types, enum, context functions — so a Scala 2 port would be a different library rather than a cross-build.

How do I report a bug, or contribute?

The repository is at codeberg.org/worxbend/codeberg4s.

Note that docs/READINESS.md lists the contribution path — CONTRIBUTING.md, issue templates, a security policy — as still missing at the time of writing. If it is still missing when you read this, an issue on the repository is the place to start.

For a decoding failure specifically, the useful bug report is error.describe: it carries the operation, the redacted URI, the JSON path and a bounded excerpt of the body, and it cannot contain a credential.