gitea4s

Authentication

GiteaConfig.withToken(baseUrl, token)
GiteaConfig.withBasic(baseUrl, username, password)
GiteaConfig.anonymous(baseUrl)

Or load from the environment. GITEA_TOKEN takes precedence; basic auth needs both GITEA_USERNAME and GITEA_PASSWORD.

GITEA_URL  GITEA_TOKEN  GITEA_USERNAME  GITEA_PASSWORD
GITEA_PAGE_SIZE  GITEA_TIMEOUT  GITEA_MAX_RETRIES
GITEA_USER_AGENT  GITEA_OTP

HOCON under the gitea4s path is also supported, and accepts the same settings:

gitea4s {
  url         = "https://gitea.example"
  token       = "..."
  page-size   = 50
  timeout     = 30s
  user-agent  = "my-app"
  max-retries = 3
}

Durations must carry a unit. Typesafe Config reads a bare number in duration position as milliseconds, so timeout = 30 would mean 30ms rather than the 30 seconds almost anyone writing it intends. It is rejected with the same message the environment reader gives. 30s and 2 minutes both work.

Settings spelled the environment's way are rejected rather than ignored — maxRetries in a gitea4s { } block is a different key from max-retries, and used to be read by nobody. Keys unrelated to gitea4s are left alone, so an application can keep its own settings alongside these.

What the library will not leak

A plain http:// base URL is accepted, so a token would travel in cleartext. That is your choice to make and the library does not second-guess it.

ZLayer usage

val layer = ZioGiteaBackend.configured(config)                     // explicit config
val layer = GiteaConfig.environmentLayer >>> ZioGiteaBackend.live   // from env
val layer = ZioGiteaBackend.usingClient(config, javaHttpClient)     // caller-owned client

OkHttpGiteaBackend has the same shape when you need the OkHttp bridge.

Pagination

Paginated list APIs return ZStream[Any, GiteaError, A]; the client fetches pages lazily and follows pagination headers. Endpoints Gitea returns as plain non-paginated lists return IO[GiteaError, Chunk[A]] instead.

client.repos.list(owner, RepoListParams(limit = Some(50))).take(100).runCollect
client.notifications.list().take(100).runCollect

Both paging fields on a *Params are honoured: limit sets the page size and page sets where the stream starts, so an interrupted crawl can be resumed without replaying what it already emitted.

client.issues.list(owner, repo, IssueListParams(page = Some(7), limit = Some(50)))

Gitea clamps limit to its own MAX_RESPONSE_ITEMS setting (50 by default), so asking for more per page than the server allows is not an error — you get the server's maximum and the stream keeps paging until the collection is exhausted.

These streams are scans, not snapshots. Each page is a separate request, so if items are inserted or deleted while a crawl is running, an item can be emitted twice or missed entirely — everything after the insertion point shifts by one. Resuming with page = Some(n) inherits the same hazard. If you need exactly-once handling, key on the item's id.

Error handling

Calls fail with the GiteaError ADT. HTTP failures preserve response bodies; decode failures include the raw body; transport failures preserve the cause; rate-limit errors carry the reset time when Gitea sends one. Resource-state failures map to explicit cases (MethodNotAllowed 405, PreconditionFailed 412, Locked 423, and so on).

client.users.me.foldZIO(
  err  => Console.printLineError(s"Gitea call failed: ${err.message}"),
  user => Console.printLine(user.login.getOrElse("<unknown>"))
)

Every case answers message, including the four that carry no message field — ServerError renders as HTTP 500, RateLimited as Rate limited until …, and TransportError as its cause's message. Both message and the retained body are capped at 8 KiB, so one failed request cannot put a multi-megabyte payload into a log line.

Unknown enum values

Gitea adds enum members between minor versions. An optional enum field carrying a value this client does not recognise decodes as None rather than failing — otherwise a single unfamiliar string would fail the whole page it arrived in, and the stream above it. Required enum fields stay strict, because there is no None to fall back to.

Retries and rate limits

Read-only requests honour GiteaConfig.maxRetries, which defaults to 3, with jittered exponential backoff. Retries cover transport failures, 429, and 500/502/503/504. Only GET and HEAD are ever retried, so a retry can never duplicate a write.

On a 429 the client honours Retry-After — both the delay-seconds and HTTP-date forms — and falls back to x-ratelimit-reset. The resulting wait is capped at 60 seconds, so a bad or hostile header cannot silently override your timeout.

The reset instant reaches you unfiltered. GiteaError.RateLimited.resetAt is whatever the server said, including a value implausibly far in the future — a proxy reporting the reset in milliseconds sends a number that is a valid epoch second thousands of years out. Treat it as untrusted input if you display or act on it; only the client's own sleep is bounded.

Each attempt is also capped end to end at five minutes. GiteaConfig.timeout alone cannot do this: it reaches the JDK as HttpRequest.timeout, which stops applying once response headers arrive, so a server that answers immediately and then sends the body one byte at a time would otherwise hang the call indefinitely. An exhausted budget is surfaced, not retried — a stalled connection is the condition least likely to have cleared a moment later. That budget bounds one attempt; if you need a single deadline covering retries and backoff sleeps too, wrap the call in ZIO's own .timeout.

Upgrading from 1.0.0: maxRetries used to default to 0. If your tests stub a 5xx or 429 and run under zio-test's TestClock, they will now block on a clock the test never advances. Set maxRetries = 0 on the config your tests use, or advance the clock.

Observability

Set a GiteaObserver to hook logging, metrics or tracing into every GiteaClient request. It runs after each call completes — with the endpoint, total duration and outcome — cannot change the result, and a faulty observer can never break a request. The default is a no-op with zero overhead.

config.withObserver(GiteaObserver.logging ++ GiteaObserver.metrics)

An observer must not block or perform unbounded I/O: it runs inline on the request's fiber, so a slow one delays a result that has already been computed. Hand work off instead. A callback that has not finished within one second is abandoned and its event dropped, so an observer can no longer withhold a completed result indefinitely.

backend-zio's streaming downloads are the exception: they send directly against the backend rather than through the executor that owns the observer, so they emit no events.

Backends

Use backend-zio by default (Java HttpClient via sttp's HttpClientZioBackend). Use backend-okhttp only when your application already standardizes on OkHttp; it adapts sttp's async OkHttp backend to ZIO and keeps OkHttp off the core dependency path.

One caveat specific to the OkHttp bridge: interrupting a fiber that is waiting on a request hands control back immediately, but the underlying OkHttp call keeps running — sttp's Future machinery drops the cancellation signal. The orphaned call is bounded by OkHttp's call timeout, which this module sets. Where cancellation latency matters, prefer backend-zio, which cancels the request when the fiber is interrupted.

Streaming downloads

client.repos.rawFile/mediaFile/archive buffer the whole body into a Chunk[Byte]. For large files and archives, backend-zio also exposes a GiteaDownloads service that streams the body lazily as ZStream[Any, GiteaError, Byte], so it never has to fit in memory:

import io.worxbend.gitea4s.backend.zio.{GiteaDownloads, ZioGiteaBackend}
import zio.ZIO
import zio.stream.ZSink

val layer = ZioGiteaBackend.downloadsConfigured(config)

ZIO.serviceWithZIO[GiteaDownloads] { downloads =>
  downloads.archive("my-org", "my-repo", "main.zip")
    .run(ZSink.fromFileName("main.zip"))   // streamed straight to disk
}.provideLayer(layer)

Streaming downloads are not retried, since a partially consumed body cannot be safely replayed. A download that stops producing fails rather than hanging: the stream fails if five minutes pass with no data at all. That budget measures the gap between chunks, not total download time, so a genuinely large archive is never cut off as long as it keeps arriving.