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
- Config errors name the invalid setting without echoing credential values.
toStringon aGiteaConfigor anAuthredacts the token, password and one-time password. Read the fields directly when you need the real value.- Credentials embedded in the URL (
https://user:pass@gitea.example) are stripped rather than rejected — nothing ever transmitted them. - Values that become header content —
token,username,password,user-agent,otp— are rejected at parse time if they contain a control character. This coversfromEnvand the HOCON readers;GiteaConfig.withTokenandAuth.Token(...)build a config directly and are not validated.
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.