openholidays

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 31, 2026 License: MIT Imports: 22 Imported by: 0

README

go-openholidays

CI codecov Go Report Card Go Reference

An idiomatic, dependency-light Go client for the public OpenHolidays API. Public holidays + school holidays per administrative subdivision (e.g. Polish ferie per województwo).

Install

go get github.com/egeek-tech/go-openholidays

Quickstart

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/egeek-tech/go-openholidays"
)

func main() {
    c := openholidays.NewClient()
    defer func() { _ = c.Close() }()
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    hs, err := c.PublicHolidays(ctx, openholidays.PublicHolidaysRequest{
        CountryIsoCode: "PL",
        ValidFrom:      openholidays.NewDate(2025, time.January, 1),
        ValidTo:        openholidays.NewDate(2025, time.December, 31),
    })
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("got %d Polish public holidays\n", len(hs))
}

The full surface — every option, helper, and error sentinel — is documented on pkg.go.dev. The runnable form of this quickstart lives at example_test.go as Example_quickstart.

More endpoints

Each of the following is a complete, standalone program — copy, paste, run. They differ only in the endpoint call; every endpoint also has its own runnable example in example_test.go, rendered under the Examples tab on pkg.go.dev.

School holidays — per administrative subdivision

The granularity competing libraries don't cover (e.g. Polish ferie zimowe for a single województwo):

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/egeek-tech/go-openholidays"
)

func main() {
    c := openholidays.NewClient()
    defer func() { _ = c.Close() }()
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    hs, err := c.SchoolHolidays(ctx, openholidays.SchoolHolidaysRequest{
        CountryIsoCode:  "PL",
        ValidFrom:       openholidays.NewDate(2025, time.January, 1),
        ValidTo:         openholidays.NewDate(2025, time.December, 31),
        SubdivisionCode: "PL-SL", // OpenHolidays' own scheme: PL-SL = Świętokrzyskie (not ISO 3166-2)
        // GroupCode:    "A",     // optional: one ferie cohort (A/B/C/D)
    })
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("got %d school-holiday spans for PL-SL\n", len(hs))
}
Countries
package main

import (
    "context"
    "fmt"
    "time"

    "github.com/egeek-tech/go-openholidays"
)

func main() {
    c := openholidays.NewClient()
    defer func() { _ = c.Close() }()
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    countries, err := c.Countries(ctx, openholidays.CountriesRequest{LanguageIsoCode: "en"})
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("got %d supported countries\n", len(countries))
}
Languages
package main

import (
    "context"
    "fmt"
    "time"

    "github.com/egeek-tech/go-openholidays"
)

func main() {
    c := openholidays.NewClient()
    defer func() { _ = c.Close() }()
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    langs, err := c.Languages(ctx, openholidays.LanguagesRequest{})
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("got %d supported languages\n", len(langs))
}
Subdivisions

The administrative-subdivision tree (16 województwa for PL; nested Bundesländer for DE):

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/egeek-tech/go-openholidays"
)

func main() {
    c := openholidays.NewClient()
    defer func() { _ = c.Close() }()
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    subs, err := c.Subdivisions(ctx, openholidays.SubdivisionsRequest{
        CountryIsoCode:  "PL",
        LanguageIsoCode: "en",
    })
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("got %d Polish subdivisions\n", len(subs))
}

Public API

Surface Symbols
Endpoint methods Client.PublicHolidays, Client.SchoolHolidays, Client.Countries, Client.Languages, Client.Subdivisions
Helpers Holiday.NameFor, Holiday.IsInRegion, Holiday.Days, Holiday.Range, Client.IsInRegion
Localized names Country.NameFor, Language.NameFor, Subdivision.NameFor
Constructors NewClient, NewDate, ParseDate, NewMemoryCache
Opt-in middleware WithRetry, WithMaxRetryWait, WithCache, WithCacheBackend, WithRequestHook, WithStrictDecoding
Configuration WithBaseURL, WithTimeout, WithUserAgent, WithLogger, WithHTTPClient
Error sentinels ErrInvalidCountry, ErrInvalidLanguage, ErrInvalidDateRange, ErrDateRangeTooLarge, ErrEmptyResponse, ErrResponseTooLarge, ErrMalformedResponse, *APIError

CLI

A small demo CLI ships alongside the library:

go install github.com/egeek-tech/go-openholidays/cmd/ohcli@latest

ohcli public PL 2025
ohcli school PL 2025 --region PL-SL
ohcli countries --json

Verifying release binaries

Released ohcli archives carry SLSA build-provenance attestations, signed via GitHub's keyless (Sigstore/Fulcio) flow, and verifiable with the gh CLI (≥ 2.49). Exit 0 means verified:

VERSION=1.0.0
gh release download "v$VERSION" --repo egeek-tech/go-openholidays --pattern 'ohcli_*_linux_amd64.tar.gz'
gh attestation verify "ohcli_${VERSION}_linux_amd64.tar.gz" --repo egeek-tech/go-openholidays

Verify the archive, not the binary. The attested subjects are the released .tar.gz/.zip archives listed in checksums.txt — not the unpacked ohcli binary and not checksums.txt itself. Verifying either of those returns HTTP 404: Not Found. Likewise, a binary built locally with go install …@latest or go build is never attested, so a 404 there is expected.

For a hardened check, pin the signing workflow with --signer-workflow egeek-tech/go-openholidays/.github/workflows/release-please.yml. Note that --source-ref refs/tags/… would fail, because the signing ref is refs/heads/master.

Architecture

See docs/design.md for the RoundTripper chain, cache architecture, retry semantics, and error model.

Contributing

See CONTRIBUTING.md for the dev loop.

License

MIT — see LICENSE.

Documentation

Overview

Package openholidays is a Go client for the OpenHolidays public-holidays and school-holidays API (https://www.openholidaysapi.org).

The library exposes public holidays, school holidays, country and language metadata, and administrative subdivisions through a clean, well-tested Go-first API. It is designed for backend engineers building HR, scheduling, education, and leave-management applications — including those that need regional school-break granularity (for example, Polish ferie per województwo) that competing libraries do not cover.

Design principles:

  • Zero runtime dependencies (no non-stdlib import outside *_test.go).
  • Full context.Context propagation on every exported call.
  • Typed errors inspectable via errors.Is / errors.As.

Quickstart:

c := openholidays.NewClient()
defer c.Close()
hs, err := c.PublicHolidays(ctx, openholidays.PublicHolidaysRequest{
    CountryIsoCode: "PL",
    ValidFrom:      openholidays.NewDate(2025, time.January, 1),
    ValidTo:        openholidays.NewDate(2025, time.December, 31),
})

See package examples on pkg.go.dev for every exported method — the runnable form lives in example_test.go as Example_quickstart and the per-method Example_<Symbol> functions.

Example (Quickstart)

Example_quickstart mirrors the README quickstart verbatim — one canonical ≤20-line snippet that fetches a year of Polish public holidays. Compile-only because PublicHolidays hits the live API. This example is the single source of truth for README §Quickstart (DOC-01).

c := openholidays.NewClient()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
hs, err := c.PublicHolidays(ctx, openholidays.PublicHolidaysRequest{
	CountryIsoCode: "PL",
	ValidFrom:      openholidays.NewDate(2025, time.January, 1),
	ValidTo:        openholidays.NewDate(2025, time.December, 31),
})
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Printf("got %d Polish public holidays\n", len(hs))

Index

Examples

Constants

This section is empty.

Variables

View Source
var CacheHitContextKey = cacheHitKeyType{}

CacheHitContextKey is the context-value key set by cacheTransport when a response is served from cache. Consumers can detect cache hits inside their WithRequestHook callback via req.Context().Value(openholidays.CacheHitContextKey). The value, when present, is the untyped boolean true; on cache miss the key is absent (Value returns nil).

The signal is one-way — there is no corresponding key for cache misses, and the absence of CacheHitContextKey in a request context is the documented miss signal (<specifics> 2).

View Source
var ErrDateRangeTooLarge = errors.New("openholidays: date range too large")

ErrDateRangeTooLarge is returned when the validFrom..validTo window spans more than 3 calendar years inclusive.

View Source
var ErrEmptyResponse = errors.New("openholidays: empty response body")

ErrEmptyResponse is returned when the upstream returns a 2xx with an empty body where a non-empty payload was required.

View Source
var ErrInvalidCountry = errors.New("openholidays: invalid country code")

ErrInvalidCountry is returned for malformed country codes (not exactly two ASCII letters after canonicalization).

View Source
var ErrInvalidDateRange = errors.New("openholidays: invalid date range")

ErrInvalidDateRange is returned when validFrom is strictly after validTo.

View Source
var ErrInvalidLanguage = errors.New("openholidays: invalid language code")

ErrInvalidLanguage is returned for malformed language codes (not exactly two ASCII letters after canonicalization).

View Source
var ErrMalformedResponse = errors.New("openholidays: malformed response")

ErrMalformedResponse is returned whenever an upstream response body cannot be turned into valid, schema-conforming data. It unifies two failure modes under one sentinel so callers have a single "the response body was malformed" check:

  • A body that is not decodable as the expected JSON shape — a syntax error or type mismatch. The underlying *json.SyntaxError / *json.UnmarshalTypeError remains recoverable via errors.As.
  • A structurally-decodable response that violates the Holiday post-decode invariants checked by validateHolidays: StartDate non-zero, EndDate non-zero, and EndDate not strictly before StartDate.

In both cases the sentinel is wrapped via fmt.Errorf with the %w verb so errors.Is(err, ErrMalformedResponse) holds through the endpoint method's caller-facing wrap. It still differentiates a malformed body from transport failures, *APIError 4xx/5xx responses, empty bodies (ErrEmptyResponse), and oversize bodies (ErrResponseTooLarge), and closes Pitfall JSON-4 (time.Time zero value masquerading as a valid Date).

View Source
var ErrResponseTooLarge = errors.New("openholidays: response too large")

ErrResponseTooLarge is returned when an upstream response exceeds the 10 MiB cap. Both boundary-truncation (Decode finishes on a valid JSON boundary, sentinel-byte read detects extra bytes) and mid-truncation (Decode surfaces io.ErrUnexpectedEOF, sentinel-byte read confirms the body has more bytes) cases produce this sentinel — see RESEARCH.md Pitfall 5 and Plan 02-03 deviation 1.

View Source
var Version = "1.0.0" // x-release-please-version

Version is the semantic version of the go-openholidays library.

It is the single source of truth for the User-Agent header sent by the HTTP client (set in a later phase) and for the --version output of the cmd/ohcli demo CLI. The value can be overridden at link time, for example:

go build -ldflags '-X github.com/egeek-tech/go-openholidays.Version=0.1.1-rc1'

Release Please updates the literal on the next line at each Release PR.

Functions

This section is empty.

Types

type APIError

type APIError struct {
	StatusCode int    // HTTP status code (>= 400 when populated by Phase 2)
	Path       string // Request path (e.g., "/PublicHolidays")
	Body       []byte // Raw response body. Phase 2 caps the populated length at 4 KiB.
	Message    string // Best-effort message parsed from upstream JSON; empty when unparseable.
}

APIError represents a non-2xx response from the upstream API.

Phase 1 ships the type, its Error method, and its Is method only; construction (reading resp.Body, parsing Message) lands in Phase 2 alongside the first endpoint method.

Callers match by status code with errors.Is:

if [errors.Is](err, &openholidays.APIError{StatusCode: 404}) { ... }

The wildcard form (zero StatusCode) matches any *APIError, allowing callers to ask "was this an API error at all?":

if [errors.Is](err, &openholidays.APIError{}) { ... }

Use errors.As to recover the populated value:

var apiErr *openholidays.APIError
if errors.As(err, &apiErr) { _ = apiErr.StatusCode }

func (*APIError) Error

func (e *APIError) Error() string

Error returns a human-readable description of the API error.

When Message is empty:

openholidays: api error <status> at <path>

Otherwise:

openholidays: api error <status> at <path>: <message>

The Body field is intentionally omitted from the Error output so that raw upstream payloads never leak into operator-visible error strings.

func (*APIError) Is

func (e *APIError) Is(target error) bool

Is supports errors.Is(err, &APIError{StatusCode: N}) status-code matching.

Semantics:

  • target is not *APIError: returns false.
  • target.StatusCode == 0 (the wildcard): matches any *APIError, i.e. "was this an API error at all?".
  • target.StatusCode != 0: matches when e.StatusCode == target.StatusCode.

The Path, Body, and Message fields on the target are intentionally ignored — they exist for diagnostics, not for matching. A future contributor extending Is to consider those fields would silently break callers that rely on status-only branching; the unit tests assert this guarantee.

type Cache

type Cache interface {
	Get(key string) (value []byte, ok bool)
	Put(key string, value []byte)
	Close() error
}

Cache is the contract for any cache backend wired via WithCache or WithCacheBackend (Plan 04). Implementations must be safe for concurrent use from multiple goroutines (CLIENT-07).

Get returns the cached value bytes and true on a hit, or nil and false on a miss or on entries that have expired according to the cache's internal clock. The returned slice is a defensive copy owned by the caller — implementations MUST NOT return a reference to internal storage. This frees callers to mutate or retain the returned slice without corrupting cache state (IN-05). Put copies value into internal storage; the caller may safely modify the supplied slice after Put returns. Close is the best-effort shutdown hook called from Client.Close — implementations should stop any sweeper goroutine and return nil on the typical path.

The interface is declared in Plan 02 (D-79) so Client.Close can call c.cache.Close() without a build error; the MemoryCache implementation lands in Plan 04.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the immutable HTTP client for the OpenHolidays API. Construct one via NewClient and reuse it across goroutines for the lifetime of the program; Client carries no per-call mutable state.

The only post-construction state mutation is the sync.Once-guarded cache.Close inside Client.Close. Endpoint methods do not read any post-Close flag; the documented Close contract is "idempotent shutdown hook that returns nil and stops the cache sweeper", NOT "fail every subsequent endpoint call". A previously-declared [atomic.Bool] `closed` flag was removed by the IN-02 follow-up because no production code path ever read it — sync.Once already enforces the cache-cleanup idempotency invariant on its own.

Phase 4 additions:

  • retry, cache: nil/zero-value defaults; their option constructors land in Plans 03/04.
  • strict: immutable after NewClient (D-91).
  • nowFunc, sleepFunc: deterministic-test seam (D-94).
  • rand: per-Client ChaCha8-seeded jitter source (D-78).
  • closeOnce: guards cache.Close inside Close (D-85).

Note: WithRequestHook, WithUserAgent, and WithLogger store their values on cfg only — they are consumed by hookTransport / headerTransport / loggingTransport (constructed in buildTransport, config.go) and never live on the Client struct after construction. WR-04 follow-up removed the previously-unread Client.requestHook field; the WR-01 (re-review) follow-up removed Client.userAgent and Client.logger for the same dead-state reason — the only production readers are the transport decorators, which read from cfg directly.

func NewClient

func NewClient(opts ...Option) *Client

NewClient constructs an *openholidays.Client by applying the supplied Options to a fresh internal configuration and returning the resulting immutable client. NewClient never returns an error: all Options either silently accept any well-formed input (e.g. WithTimeout(0) means "no SDK-imposed timeout") or fall back to a documented default (e.g. WithLogger(nil) falls back to slog.Default()).

Defaults applied when no Option supplies the field:

  • HTTP client: a zero-valued *http.Client (no caller Timeout)
  • Base URL: the upstream production host (D-36 / PROJECT.md)
  • User-Agent: the go-openholidays brand string + Version
  • Logger: slog.Default()
  • Timeout: fifteen seconds (per-request, applied via context.WithTimeout)
  • nowFunc: time.Now (D-94)
  • sleepFunc: ctxSleep — a ctx-aware timer-based helper (D-94)
  • rand: per-Client *math/rand/v2.Rand seeded by crypto/rand (D-78)

The returned Client is safe for concurrent use from any goroutine (verified by TestClient_ConcurrentAccess under the race detector in a later plan; this plan ships TestClient_Close which mechanically asserts the sync.Once-guarded idempotency invariant under 100 parallel goroutines).

Example

ExampleNewClient demonstrates functional-options composition — every behavior knob (User-Agent, timeout, retry, in-memory cache) is layered on the constructor without an options struct. Compile-only — no network is touched until an endpoint method is called.

c := openholidays.NewClient(
	openholidays.WithUserAgent("myapp/1.0"),
	openholidays.WithTimeout(15*time.Second),
	openholidays.WithRetry(3, 250*time.Millisecond),
	openholidays.WithCache(time.Hour),
)
defer c.Close()
_ = c

func (*Client) Close

func (c *Client) Close() error

Close is the idempotent shutdown hook. It best-effort calls cache.Close when a cache backend was wired via WithCache or WithCacheBackend (D-85). Safe to call from any goroutine; subsequent calls return nil unchanged. Cache.Close errors are intentionally swallowed — the cache's contract is best-effort cleanup and a Close failure on the cache should not surface to a caller draining their Client in defer client.Close() (CLIENT-08).

Mechanical guarantee (D-40 / D-85 / CLIENT-08): the cache.Close call is guarded by sync.Once, so concurrent calls from multiple goroutines under the race detector neither race nor produce a non-nil error. The "subsequent calls return nil unchanged" sentence refers to subsequent Close calls — Close does NOT gate post-Close endpoint dispatch; that is by design (an [atomic.Bool] `closed` flag was removed by the IN-02 follow-up after the re-review confirmed no production reader).

Example

ExampleClient_Close demonstrates the idempotent shutdown idiom: deferring Close immediately after NewClient guarantees the cache sweeper (when one was wired via WithCache) stops on the way out. Close is safe to call from any goroutine and returns nil on every invocation after the first. Compile-only — no observable output.

c := openholidays.NewClient()
defer c.Close()
// Calling Close twice is safe — the second call is a no-op.
_ = c.Close()

func (*Client) Countries

func (c *Client) Countries(ctx context.Context, req CountriesRequest) ([]Country, error)

Countries fetches the list of supported countries from the upstream OpenHolidays API. Each returned Country carries an IsoCode, a per-language localized Name array (look up a specific language via Country.NameFor), and the country's OfficialLanguages list.

Request shape: Countries takes a CountriesRequest second argument so its signature is symmetric with every other Phase 3 endpoint (Languages, Subdivisions, PublicHolidays, SchoolHolidays). The zero value CountriesRequest{} reproduces the Phase 2 single-arg Countries(ctx) behavior verbatim (D-51 / D-52 / CL-08). The optional LanguageIsoCode filter restricts the returned Country.Name entries to that language only.

Per-request timeout: when the Client was constructed with WithTimeout(d) and d > 0, Countries wraps ctx via context.WithTimeout(ctx, d) before dispatching. Cancellation of the caller's ctx interrupts the in-flight HTTP within ≤ 100 ms (CLIENT-09); errors.Is(err, context.Canceled) holds through the fmt.Errorf %w wrap returned on transport-level failures.

Error handling:

  • A non-empty req.LanguageIsoCode that fails client-side shape validation returns an error wrapping ErrInvalidLanguage without reaching the network (D-56).
  • 4xx and 5xx upstream responses produce *APIError with the StatusCode, a parsed Message (RFC 7807 ProblemDetails priority: detail → title → error), and the raw response body capped at 4 KiB (Phase 1 D-17). Use errors.As(err, &apiErr) to recover the populated value.
  • 2xx with an empty body returns an error that errors.Is matches against ErrEmptyResponse.
  • Upstream responses exceeding the 10 MiB cap return an error that errors.Is matches against ErrResponseTooLarge.
  • JSON decode failures wrap the underlying error with the "openholidays: decode /Countries: " prefix.

Concurrent use: the Client is immutable after NewClient, so Countries is safe to call from any goroutine without external synchronization (CLIENT-07).

Example

ExampleClient_Countries demonstrates the Countries listing endpoint. The optional LanguageIsoCode filter narrows the localized Name entries upstream returns to a single language. Compile-only — Countries issues HTTP.

c := openholidays.NewClient()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

countries, err := c.Countries(ctx, openholidays.CountriesRequest{
	LanguageIsoCode: "en",
})
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Printf("got %d supported countries\n", len(countries))

func (*Client) IsInRegion

func (c *Client) IsInRegion(ctx context.Context, h Holiday, code string) (bool, error)

IsInRegion reports whether the holiday h applies to the administrative subdivision identified by code, accounting for hierarchical subdivision nesting (CL-09 / D-59). Where Holiday.IsInRegion only performs a flat match against h.Subdivisions[].Code, this method additionally walks the upstream /Subdivisions tree to detect whether code is a descendant of any subdivision the holiday applies to.

Fast paths (no HTTP issued):

  1. h.Nationwide → returns (true, nil) — a nationwide holiday applies everywhere, including the empty-string code (WR-06).
  2. code == "" → returns (false, nil) — defensive guard on non-nationwide holidays only.
  3. Flat strings.EqualFold match against h.Subdivisions[].Code → returns (true, nil).
  4. len(h.Subdivisions) == 0 (and not Nationwide) → returns (false, nil) — there is no country context to fetch a tree for.

Hierarchical path: when none of the fast paths fire, IsInRegion issues an HTTP GET to /Subdivisions for the country implied by the prefix of h.Subdivisions[0].Code (e.g. "PL" from "PL-SL", "DE" from "DE-BY"). It then builds a child→parent index from the recursive Subdivision.Children shape and walks upward from code until either a strings.EqualFold match against an entry in h.Subdivisions is found (returns (true, nil)) or the root is reached (returns (false, nil)). Any error from c.Subdivisions is surfaced verbatim as (false, err).

Cost note: this is the ONE Phase 3 method that issues hidden I/O. Repeated calls in a hot loop incur a /Subdivisions round-trip per call. Phase 4's cache transport will memoize /Subdivisions per (baseURL, countryIsoCode); callers that need cheap repeated lookups on Phase 3 should perform the fetch once via c.Subdivisions and build their own parent-index helper.

Cycle defense: the upward walk is bounded by len(parentIdx)+1 iterations. A cyclic parent-index (malformed upstream data where two entries each claim the other as a child) yields a bounded walk and returns (false, nil) instead of looping forever — per RESEARCH.md Pitfall 4 and ASVS V5.1.4.

Concurrent use: this method is safe to call from any goroutine because *Client is immutable after NewClient and the inner c.Subdivisions call is itself concurrency-safe (CLIENT-07).

Example

ExampleClient_IsInRegion demonstrates the hierarchical region-membership check — unlike Holiday.IsInRegion (a pure flat match) this method may walk the upstream /Subdivisions tree to detect whether code is a descendant of any subdivision the holiday applies to. Compile-only because the upward walk may issue HTTP for hierarchical lookups.

c := openholidays.NewClient()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

h := openholidays.Holiday{
	Nationwide:   false,
	Subdivisions: []openholidays.SubdivisionRef{{Code: "PL-SL"}},
}
ok, err := c.IsInRegion(ctx, h, "PL-SL")
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Printf("holiday applies in PL-SL: %t\n", ok)

func (*Client) Languages

func (c *Client) Languages(ctx context.Context, req LanguagesRequest) ([]Language, error)

Languages fetches the list of supported languages from the upstream OpenHolidays API. Each returned Language carries an IsoCode (ISO 639-1 uppercase on the wire) and a per-language localized Name array (look up a specific language via Language.NameFor).

Request shape: Languages takes a LanguagesRequest second argument so its signature is symmetric with every other Phase 3 endpoint (Countries, Subdivisions, PublicHolidays, SchoolHolidays). The zero value LanguagesRequest{} reproduces the upstream's unfiltered /Languages behavior (D-51 / D-52 / CL-08). The optional LanguageIsoCode filter restricts the returned Language.Name entries to that language only.

Per-request timeout: when the Client was constructed with WithTimeout(d) and d > 0, Languages wraps ctx via context.WithTimeout(ctx, d) before dispatching. Cancellation of the caller's ctx interrupts the in-flight HTTP within ≤ 100 ms (CLIENT-09); errors.Is(err, context.Canceled) holds through the fmt.Errorf %w wrap returned on transport-level failures.

Error handling:

  • A non-empty req.LanguageIsoCode that fails client-side shape validation returns an error wrapping ErrInvalidLanguage without reaching the network (D-56).
  • 4xx and 5xx upstream responses produce *APIError with the StatusCode, a parsed Message (RFC 7807 ProblemDetails priority: detail → title → error), and the raw response body capped at 4 KiB (Phase 1 D-17). Use errors.As(err, &apiErr) to recover the populated value.
  • 2xx with an empty body returns an error that errors.Is matches against ErrEmptyResponse.
  • Upstream responses exceeding the 10 MiB cap return an error that errors.Is matches against ErrResponseTooLarge.
  • JSON decode failures wrap the underlying error with the "openholidays: decode /Languages: " prefix.

Concurrent use: the Client is immutable after NewClient, so Languages is safe to call from any goroutine without external synchronization (CLIENT-07).

Example

ExampleClient_Languages demonstrates the Languages listing endpoint — returns every ISO 639-1 language the upstream API can localize its responses in. Compile-only — Languages issues HTTP.

c := openholidays.NewClient()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

langs, err := c.Languages(ctx, openholidays.LanguagesRequest{})
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Printf("got %d supported languages\n", len(langs))

func (*Client) PublicHolidays

func (c *Client) PublicHolidays(ctx context.Context, req PublicHolidaysRequest) ([]Holiday, error)

PublicHolidays fetches the list of public (statutory) holidays for a country in a date window from the upstream OpenHolidays API. Each returned Holiday carries an ID, a StartDate/EndDate pair (Date.Equal for single-day holidays), a Type, a per-language localized Name array (look up a specific language via Holiday.NameFor), Nationwide / RegionalScope / TemporalScope flags, and the optional Subdivisions / Groups / Tags / Comment / Quality fields when the upstream populates them.

Request shape: PublicHolidays takes a PublicHolidaysRequest second argument symmetric with every other Phase 3 endpoint method (Countries, Languages, Subdivisions, SchoolHolidays). The uniform (ctx, Request) shape is locked by D-51 / CL-08.

Per-request timeout: when the Client was constructed with WithTimeout(d) and d > 0, PublicHolidays wraps ctx via context.WithTimeout(ctx, d) before dispatching. Cancellation of the caller's ctx interrupts the in-flight HTTP within ≤ 100 ms (CLIENT-09); errors.Is(err, context.Canceled) holds through the fmt.Errorf %w wrap returned on transport-level failures.

Error handling:

  • An empty or malformed req.CountryIsoCode returns an error wrapping ErrInvalidCountry without reaching the network (D-56).
  • req.ValidFrom after req.ValidTo returns an error wrapping ErrInvalidDateRange (D-22 / VALID-02). A window spanning more than 3 calendar years (anchored at ValidTo, stepping backward) returns an error wrapping ErrDateRangeTooLarge (D-22 / VALID-03).
  • A non-empty req.LanguageIsoCode that fails shape validation returns an error wrapping ErrInvalidLanguage without reaching the network.
  • 4xx and 5xx upstream responses produce *APIError with the StatusCode, a parsed Message (RFC 7807 ProblemDetails priority: detail → title → error), and the raw response body capped at 4 KiB (Phase 1 D-17). Use errors.As(err, &apiErr) to recover the populated value.
  • 2xx with an empty body returns an error that errors.Is matches against ErrEmptyResponse.
  • Upstream responses exceeding the 10 MiB cap return an error that errors.Is matches against ErrResponseTooLarge.
  • A structurally-decodable response that violates the Holiday post-decode invariants (zero StartDate, zero EndDate, or EndDate strictly before StartDate) returns an error that errors.Is matches against ErrMalformedResponse (D-65 / D-66 / CL-12). The error message includes the offending Holiday's ID and the failing predicate so an upstream-regression bug report has actionable diagnostics.
  • JSON decode failures wrap the underlying error with the "openholidays: decode /PublicHolidays: " prefix.

Concurrent use: the Client is immutable after NewClient, so PublicHolidays is safe to call from any goroutine without external synchronization (CLIENT-07).

Example

ExampleClient_PublicHolidays demonstrates the canonical "fetch one year of public holidays" call against a Client. Compile-only — PublicHolidays issues HTTP to the live OpenHolidays API.

c := openholidays.NewClient()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

hs, err := c.PublicHolidays(ctx, openholidays.PublicHolidaysRequest{
	CountryIsoCode: "PL",
	ValidFrom:      openholidays.NewDate(2025, time.January, 1),
	ValidTo:        openholidays.NewDate(2025, time.December, 31),
})
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Printf("got %d Polish public holidays\n", len(hs))

func (*Client) SchoolHolidays

func (c *Client) SchoolHolidays(ctx context.Context, req SchoolHolidaysRequest) ([]Holiday, error)

SchoolHolidays fetches the list of school holidays (e.g. Polish ferie zimowe, ferie letnie, wiosenna/zimowa przerwa świąteczna) for a country in a date window from the upstream OpenHolidays API. Each returned Holiday carries an ID, a StartDate/EndDate pair (multi-day for school breaks — Holiday.Days returns 14 for the canonical Polish ferie zimowe Śląskie 2025 entry), a Type (typically HolidayTypeSchool), a per-language localized Name array (look up a specific language via Holiday.NameFor), Nationwide / RegionalScope / TemporalScope flags, and the optional Subdivisions / Groups / Tags / Comment / Quality fields when the upstream populates them.

Request shape: SchoolHolidays takes a SchoolHolidaysRequest second argument symmetric with every other Phase 3 endpoint method (Countries, Languages, Subdivisions, PublicHolidays). The uniform (ctx, Request) shape is locked by D-51 / CL-08. SchoolHolidaysRequest adds one optional field — GroupCode — beyond PublicHolidaysRequest (D-54 / CL-13).

Per-request timeout: when the Client was constructed with WithTimeout(d) and d > 0, SchoolHolidays wraps ctx via context.WithTimeout(ctx, d) before dispatching. Cancellation of the caller's ctx interrupts the in-flight HTTP within ≤ 100 ms (CLIENT-09); errors.Is(err, context.Canceled) holds through the fmt.Errorf %w wrap returned on transport-level failures.

Error handling:

  • An empty or malformed req.CountryIsoCode returns an error wrapping ErrInvalidCountry without reaching the network (D-56).
  • req.ValidFrom after req.ValidTo returns an error wrapping ErrInvalidDateRange (D-22 / VALID-02). A window spanning more than 3 calendar years (anchored at ValidTo, stepping backward) returns an error wrapping ErrDateRangeTooLarge (D-22 / VALID-03).
  • A non-empty req.LanguageIsoCode that fails shape validation returns an error wrapping ErrInvalidLanguage without reaching the network.
  • 4xx and 5xx upstream responses produce *APIError with the StatusCode, a parsed Message (RFC 7807 ProblemDetails priority: detail → title → error), and the raw response body capped at 4 KiB (Phase 1 D-17). Use errors.As(err, &apiErr) to recover the populated value.
  • 2xx with an empty body returns an error that errors.Is matches against ErrEmptyResponse.
  • Upstream responses exceeding the 10 MiB cap return an error that errors.Is matches against ErrResponseTooLarge.
  • A structurally-decodable response that violates the Holiday post-decode invariants (zero StartDate, zero EndDate, or EndDate strictly before StartDate) returns an error that errors.Is matches against ErrMalformedResponse (D-65 / D-66 / CL-12). The error message includes the offending Holiday's ID and the failing predicate so an upstream-regression bug report has actionable diagnostics.
  • JSON decode failures wrap the underlying error with the "openholidays: decode /SchoolHolidays: " prefix.

Concurrent use: the Client is immutable after NewClient, so SchoolHolidays is safe to call from any goroutine without external synchronization (CLIENT-07).

Example

ExampleClient_SchoolHolidays demonstrates the regional filter — the SubdivisionCode argument restricts the upstream result to school holidays applying to that administrative subdivision (e.g. "PL-SL" = Śląskie). Compile-only — SchoolHolidays issues HTTP to the live OpenHolidays API.

c := openholidays.NewClient()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

hs, err := c.SchoolHolidays(ctx, openholidays.SchoolHolidaysRequest{
	CountryIsoCode:  "PL",
	ValidFrom:       openholidays.NewDate(2025, time.January, 1),
	ValidTo:         openholidays.NewDate(2025, time.December, 31),
	SubdivisionCode: "PL-SL",
})
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Printf("got %d Polish school holidays for PL-SL\n", len(hs))

func (*Client) Subdivisions

func (c *Client) Subdivisions(ctx context.Context, req SubdivisionsRequest) ([]Subdivision, error)

Subdivisions fetches the administrative subdivisions of the country named by req.CountryIsoCode from the upstream OpenHolidays API. Each returned Subdivision carries a Code (e.g. "PL-SL"), a per-language localized Name array (look up a specific language via Subdivision.NameFor), a Category label, and — for countries whose subdivisions are organized hierarchically (e.g. Germany at the Bundesländer→Regierungsbezirke level) — a recursive Children slice referencing the same Subdivision type.

Per-request timeout: when the Client was constructed with WithTimeout(d) and d > 0, Subdivisions wraps ctx via context.WithTimeout(ctx, d) before dispatching. Cancellation of the caller's ctx interrupts the in-flight HTTP within ≤ 100 ms (CLIENT-09); errors.Is(err, context.Canceled) holds through the fmt.Errorf %w wrap returned on transport-level failures.

Error handling:

  • An empty or malformed req.CountryIsoCode returns an error wrapping ErrInvalidCountry without reaching the network (D-56).
  • A non-empty req.LanguageIsoCode that fails client-side shape validation returns an error wrapping ErrInvalidLanguage without reaching the network (D-56).
  • 4xx and 5xx upstream responses produce *APIError with the StatusCode, a parsed Message (RFC 7807 ProblemDetails priority: detail → title → error), and the raw response body capped at 4 KiB (Phase 1 D-17). Use errors.As(err, &apiErr) to recover the populated value.
  • 2xx with an empty body returns an error that errors.Is matches against ErrEmptyResponse.
  • Upstream responses exceeding the 10 MiB cap return an error that errors.Is matches against ErrResponseTooLarge.
  • JSON decode failures wrap the underlying error with the "openholidays: decode /Subdivisions: " prefix.

Recursion: Subdivision.Children is a (potentially deeply nested) slice of the same Subdivision type. The recursive shape is the reason Client.IsInRegion exists (CL-09) — given a leaf-level region code like "DE-BY-AU", it walks the parent chain to detect membership when only the parent code (e.g. "DE-BY") appears in Holiday.Subdivisions. See Client.IsInRegion godoc.

Concurrent use: the Client is immutable after NewClient, so Subdivisions is safe to call from any goroutine without external synchronization (CLIENT-07).

Trust model (IN-04): the upstream is assumed to return only subdivisions belonging to the requested country. The library does NOT post-decode-verify the country prefix on Subdivision.Code values; a hostile or buggy upstream that returns mixed-country codes would produce undefined behavior in downstream helpers (in particular Client.IsInRegion's hierarchical walk, which keys its parent-index by Subdivision.Code). The current v0.x scope is intentionally trust-the-upstream; a post-decode country-prefix filter is a v0.2 deviation candidate.

Example

ExampleClient_Subdivisions demonstrates fetching the administrative- subdivision tree for a country. For Poland the response is a flat list of 16 województwa; for Germany it nests Bundesländer → Regierungsbezirke. Compile-only — Subdivisions issues HTTP.

c := openholidays.NewClient()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

subs, err := c.Subdivisions(ctx, openholidays.SubdivisionsRequest{
	CountryIsoCode:  "PL",
	LanguageIsoCode: "en",
})
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Printf("got %d Polish subdivisions\n", len(subs))

type CountriesRequest

type CountriesRequest struct {
	// LanguageIsoCode is an optional ISO 639-1 language filter.
	LanguageIsoCode string
}

CountriesRequest carries the optional filter exposed by the upstream /Countries endpoint. The zero value (CountriesRequest{}) reproduces the Phase 2 unfiltered behavior verbatim and is the recommended call shape when no filter is needed.

Fields:

  • LanguageIsoCode is an optional ISO 639-1 two-letter language code (case-insensitive; canonicalized to uppercase before being sent on the wire). When non-empty, the request includes the corresponding languageIsoCode query parameter and the upstream returns only the localized Country.Name entries in that language. When empty, the parameter is omitted and the upstream returns all localized names for each Country (D-54 / D-55 / CL-13).

Validation: non-empty LanguageIsoCode is validated client-side via validateLanguage (D-56) before any HTTP request is made; a malformed value returns an error wrapping ErrInvalidLanguage without reaching the network.

type Country

type Country struct {
	// IsoCode is the ISO 3166-1 alpha-2 country code (uppercase).
	IsoCode string `json:"isoCode"`
	// Name is the per-language localized country name.
	Name []LocalizedText `json:"name"`
	// OfficialLanguages lists the ISO 639-1 codes of the country's
	// official languages.
	OfficialLanguages []string `json:"officialLanguages"`
}

Country is the response shape for /Countries returned by the upstream OpenHolidays API. Use Country.NameFor to look up the localized country name for a given language code.

func (Country) NameFor

func (c Country) NameFor(lang string) (string, bool)

NameFor returns the localized country name for the given ISO 639-1 language code and reports whether a matching entry was found. Language matching is case-insensitive (strings.EqualFold) so "PL" matches a "pl" entry. When the requested language is absent, NameFor returns ("", false) — it does NOT fall back to another entry, so a false ok unambiguously means "not localized in lang" (callers wanting a fallback choose one explicitly).

The accessor is named NameFor (not Name) because Country already has a Name field of type []LocalizedText — a method named Name(lang) would collide with the field. The same shape is used by Language.NameFor and Subdivision.NameFor (CL-05).

Example

ExampleCountry_NameFor demonstrates the localized-name lookup pattern shared by Country, Language, and Subdivision. Matching is case-insensitive (strings.EqualFold); on a language miss NameFor returns ("", false) with no fallback.

c := openholidays.Country{
	IsoCode: "PL",
	Name: []openholidays.LocalizedText{
		{Language: "en", Text: "Poland"},
		{Language: "de", Text: "Polen"},
	},
}
name, ok := c.NameFor("de")
fmt.Println(name, ok)
Output:
Polen true

type Date

type Date struct {
	time.Time
}

Date is a calendar date (no timezone) returned by the OpenHolidays API.

Internally, Date wraps a time.Time normalized to UTC midnight so the embedded time.Time methods (Year, Month, Day, Format, IsZero, ...) work naturally without timezone surprises. Construct a Date via NewDate or ParseDate, or decode one from a JSON "YYYY-MM-DD" string via the standard encoding/json package.

The zero Date{} represents January 1 of year 1 (matching the time.Time zero), and round-trips to the JSON literal "0001-01-01". Use IsZero to distinguish a populated Date from an absent one.

func NewDate

func NewDate(year int, month time.Month, day int) Date

NewDate constructs a Date at UTC midnight on the given calendar year, month, and day. The returned Date.Location() is always time.UTC and the time-of-day fields (Hour, Minute, Second, Nanosecond) are all zero.

Example

ExampleNewDate demonstrates the UTC-midnight Date constructor. Date.String emits the YYYY-MM-DD wire format used by the upstream API.

d := openholidays.NewDate(2025, time.December, 24)
fmt.Println(d)
Output:
2025-12-24

func ParseDate

func ParseDate(s string) (Date, error)

ParseDate parses a YYYY-MM-DD string and returns the corresponding UTC-midnight Date.

An empty string returns an error wrapping the internal empty-date sentinel. Malformed input returns a wrapped time.Parse error containing the offending value in quoted form for diagnostics.

Example

ExampleParseDate demonstrates parsing a YYYY-MM-DD wire string into a UTC-midnight Date. Empty input and malformed strings return a wrapped error (see Date.UnmarshalJSON for the JSON form).

d, err := openholidays.ParseDate("2025-12-24")
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Println(d)
Output:
2025-12-24

func (Date) After

func (d Date) After(other Date) bool

After reports whether d is strictly after other in calendar order. Both operands are normalized to UTC midnight before comparison.

func (Date) Before

func (d Date) Before(other Date) bool

Before reports whether d is strictly before other in calendar order. Both operands are normalized to UTC midnight before comparison.

func (Date) Compare

func (d Date) Compare(other Date) int

Compare returns -1 if d is before other, 0 if equal, +1 if after. Both operands are normalized to UTC midnight before comparison.

func (Date) DaysUntil

func (d Date) DaysUntil(other Date) int

DaysUntil returns the number of calendar days from d to other — the conventional exclusive delta.

For d == other (same calendar day) it returns 0; for other one day after d it returns 1; for d strictly after other it returns a negative count. To get the inclusive number of days a [d, other] span covers, add 1 — see Holiday.Days.

The implementation operates on UTC-midnight operands so the result is calendar-correct across DST boundaries (DST cannot perturb a difference of UTC-midnight times because both are at 00:00 UTC).

func (Date) Equal

func (d Date) Equal(other Date) bool

Equal reports whether two Dates represent the same calendar day.

Both operands are defensively normalized to UTC midnight before comparison so a Date constructed outside NewDate/ParseDate (for example via a struct literal with a non-UTC time.Time) still compares calendar-correctly.

func (Date) MarshalJSON

func (d Date) MarshalJSON() ([]byte, error)

MarshalJSON emits the Date as a JSON string in YYYY-MM-DD form.

The zero Date{} round-trips to "0001-01-01" — symmetric with time.Time's MarshalJSON semantics. Callers detect missing dates via Date.IsZero, not by checking against the marshaled string.

func (Date) String

func (d Date) String() string

String returns the Date in YYYY-MM-DD form.

This shadows the embedded time.Time.String() method to avoid the noisy "0001-01-01 00:00:00 +0000 UTC" format that time.Time produces by default; the YYYY-MM-DD shape matches the JSON wire format and is friendlier in CLI table output.

func (*Date) UnmarshalJSON

func (d *Date) UnmarshalJSON(b []byte) error

UnmarshalJSON parses YYYY-MM-DD JSON strings into the Date.

Both the JSON literal null and the empty JSON string "" are rejected with an error wrapping the internal empty-date sentinel — silent zero values are not produced. Non-string JSON tokens (numbers, booleans, objects, ...) return a "must be a JSON string" error with the offending bytes echoed for diagnostics. Malformed date strings return a wrapped time.Parse error.

On success, the receiver is replaced with a UTC-midnight Date.

type GroupRef

type GroupRef struct {
	// Code is the group code (e.g. "A").
	Code string `json:"code"`
	// ShortName is the human-readable short name of the group.
	ShortName string `json:"shortName"`
}

GroupRef is a lightweight reference embedded in Holiday.Groups and Subdivision.Groups (e.g. Polish ferie cohorts A/B/C/D used to stagger school-holiday windows across regions).

The upstream calls this shape GroupReference; the library uses GroupRef for symmetry with SubdivisionRef.

type Holiday

type Holiday struct {
	// ID is the upstream-assigned UUID identifying the holiday.
	ID string `json:"id"`
	// StartDate is the first calendar day of the holiday (UTC midnight).
	StartDate Date `json:"startDate"`
	// EndDate is the last calendar day of the holiday (UTC midnight).
	// For single-day holidays EndDate equals StartDate.
	EndDate Date `json:"endDate"`
	// Type is the upstream HolidayType enum.
	//
	// The six PascalCase values verified against the upstream OpenAPI spec
	// on 2026-05-27 are HolidayTypePublic, HolidayTypeBank,
	// HolidayTypeOptional, HolidayTypeSchool, HolidayTypeBackToSchool, and
	// HolidayTypeEndOfLessons. Callers MUST be prepared for upstream to
	// return values outside this set: HolidayType is a typed-string alias,
	// so any string the server emits unmarshal-decodes into Type as-is
	// (the default lenient decoder accepts it; the opt-in strict decoder
	// surfaces unknown *fields* but not unknown enum *values*). Use
	// HolidayType.IsKnown to test for membership in the documented set
	// before branching on the value.
	Type HolidayType `json:"type"`
	// Name is the per-language localized name of the holiday (array shape,
	// not a map — Pitfall OH-3).
	Name []LocalizedText `json:"name"`
	// Nationwide reports whether the holiday applies to the entire country.
	// When false, consult Subdivisions for the affected regions.
	Nationwide bool `json:"nationwide"`
	// RegionalScope is the upstream regional-scope marker, a typed enum with
	// the closed set RegionalScopeNational / Regional / Local. Use
	// RegionalScope.IsKnown before branching, as the upstream may emit a value
	// outside the documented set.
	RegionalScope RegionalScope `json:"regionalScope"`
	// TemporalScope is the upstream temporal-scope marker, a typed enum with
	// the closed set TemporalScopeFullDay / HalfDay. Use TemporalScope.IsKnown
	// before branching, as the upstream may emit a value outside the set.
	TemporalScope TemporalScope `json:"temporalScope"`
	// Comment is optional per-language commentary on the holiday. Nullable
	// upstream; emitted only when populated.
	Comment []LocalizedText `json:"comment,omitempty"`
	// Subdivisions lists the administrative regions the holiday applies to
	// when Nationwide is false. Nullable upstream.
	Subdivisions []SubdivisionRef `json:"subdivisions,omitempty"`
	// Groups lists the group memberships of the holiday (e.g. Polish ferie
	// cohorts). Nullable upstream.
	Groups []GroupRef `json:"groups,omitempty"`
	// Tags is a free-form tag list. Nullable upstream and newly verified
	// during 2026-05-27 OpenAPI fetch — not previously documented in
	// REQUIREMENTS.md (Assumption A5).
	Tags []string `json:"tags,omitempty"`
	// Quality is a schema-drift field observed in real upstream responses
	// but absent from the OpenAPI spec (Pitfall OH-2). The default lenient
	// decoder tolerates both presence and absence. Phase 4's opt-in strict
	// decoder will surface this field's presence for downstream callers
	// that care.
	Quality string `json:"quality,omitempty"`
}

Holiday represents one public or school holiday returned by the OpenHolidays API. Field order, names, and JSON tags match the verified upstream OpenAPI spec (2026-05-27).

Multi-day holidays (school holidays, multi-day public observances) have StartDate < EndDate. For single-day public holidays, StartDate == EndDate. Always use both fields; do not assume a Holiday is a single calendar day (Pitfall TZ-3).

Nullable upstream fields (Comment, Subdivisions, Groups, Tags) carry `omitempty` so a marshaled Holiday emits the same wire shape upstream produces. Quality is a schema-drift field observed in real responses but absent from the OpenAPI spec (Pitfall OH-2); the default lenient decoder tolerates both its presence and absence.

func (Holiday) Days

func (h Holiday) Days() int

Days returns the inclusive count of calendar days the holiday spans.

For a single-day holiday (StartDate == EndDate), Days returns 1. For a multi-day holiday, Days returns the inclusive count from StartDate to EndDate — for example, the Polish ferie zimowe Śląskie 2025 span (2025-01-18 to 2025-01-31) returns 14.

When EndDate is strictly before StartDate — a malformed Holiday the endpoint-layer validateHolidays would have rejected but a hand-built Holiday can carry — Days returns 0 (defensive clamp, WR-03). Callers branching on h.Days() > N therefore get a defined, non-negative value for every Holiday they can hold.

The implementation adds 1 to Date.DaysUntil (the exclusive day delta) to convert it to an inclusive span count. DaysUntil operates on UTC-midnight operands and is therefore calendar-correct across DST boundaries (Phase 1 D-10 / Pitfall TZ-2).

Example

ExampleHoliday_Days demonstrates the inclusive day-count helper. For a holiday spanning 2025-01-01 through 2025-01-07 inclusive, Days returns 7.

h := openholidays.Holiday{
	StartDate: openholidays.NewDate(2025, time.January, 1),
	EndDate:   openholidays.NewDate(2025, time.January, 7),
}
fmt.Println(h.Days())
Output:
7

func (Holiday) IsInRegion

func (h Holiday) IsInRegion(code string) bool

IsInRegion reports whether the holiday h applies to the administrative subdivision identified by code. The match is flat and side-effect-free:

  1. A nationwide holiday returns true for any code (including the empty string) — a nationwide holiday applies everywhere by definition, so "applies in <code>" is true regardless of <code> (WR-06).
  2. An empty code on a non-nationwide holiday returns false (defensive — no panic, no false positive on hand-built empty input).
  3. Otherwise, IsInRegion iterates Holiday.Subdivisions and returns true on the first strings.EqualFold(s.Code, code) match.
  4. Returns false otherwise.

IsInRegion does not recurse into a subdivision tree — Holiday only carries a flat []SubdivisionRef and the upstream-returned subdivisions are already top-level matches for the holiday. Callers that need to ask whether a child subdivision (e.g. "PL-SL-KAT" under "PL-SL") is covered by a holiday that applies to its parent should use Client.IsInRegion instead — that method fetches /Subdivisions and walks Subdivision.Children to answer hierarchical questions (CL-09).

Example

ExampleHoliday_IsInRegion demonstrates the flat (no-HTTP) region check on a Holiday value. A nationwide holiday returns true for any code; a holiday with explicit Subdivisions returns true only on a strings.EqualFold match.

h := openholidays.Holiday{
	Nationwide:   false,
	Subdivisions: []openholidays.SubdivisionRef{{Code: "PL-SL"}},
}
fmt.Println(h.IsInRegion("PL-SL"), h.IsInRegion("PL-WP"))
Output:
true false

func (Holiday) NameFor

func (h Holiday) NameFor(lang string) (string, bool)

NameFor returns the localized holiday name for the given ISO 639-1 language code and reports whether a matching entry was found. Language matching is case-insensitive (strings.EqualFold) so "PL" matches a "pl" entry. When the requested language is absent, NameFor returns ("", false) — it does NOT fall back to another entry, so a false ok unambiguously means "not localized in lang" (callers wanting a fallback choose one explicitly).

The accessor is named NameFor (not Name) because Holiday already has a Name field of type []LocalizedText — a method named Name(lang) would collide with the field. The same shape is used by Country.NameFor, Language.NameFor, and Subdivision.NameFor (CL-05 / CL-10).

Example

ExampleHoliday_NameFor demonstrates the localized-name lookup. NameFor reports whether the requested language was found; on a miss it returns ("", false) with no fallback. The Polish literal matches the testdata-fixture exception in CONVENTIONS.md Rule 1.

h := openholidays.Holiday{
	Name: []openholidays.LocalizedText{
		{Language: "pl", Text: "Boże Narodzenie"},
		{Language: "en", Text: "Christmas Day"},
	},
}
pl, okPL := h.NameFor("pl")
fmt.Println(pl, okPL)
xx, okXX := h.NameFor("xx") // not present — no fallback
fmt.Printf("%q %v\n", xx, okXX)
Output:
Boże Narodzenie true
"" false

func (Holiday) Range

func (h Holiday) Range() iter.Seq[Date]

Range returns an iterator that yields every Date from StartDate to EndDate inclusive. For a single-day holiday (StartDate == EndDate), the iterator yields exactly one Date. For a multi-day holiday, it yields each calendar day in chronological order.

The iterator is single-use per Go 1.23 range-over-func semantics: the yield function returns false when the consumer breaks out of the range loop, and the body must not call yield again after that. The returned closure honors this contract by returning immediately on a false yield.

Every yielded Date is rebuilt via NewDate(year, month, day), so each yielded value is at UTC midnight regardless of the receiver's internal time.Time location. Iteration advances via time.Time.AddDate(0, 0, 1), which is calendar-correct across DST boundaries because the operands are UTC-midnight (Phase 1 D-10 / Pitfall TZ-2 / Pitfall 3).

When EndDate is strictly before StartDate, Range yields nothing. Such Holiday values are rejected by the endpoint-layer validateHolidays pass before they reach the caller for endpoint-returned Holidays, but the defensive guard exists so hand-built Holidays do not panic.

The yielded element type is Date, not time.Time — a deliberate deviation from ROADMAP success criterion #4's literal iter.Seqtime.Time so the iterator composes directly with Date math helpers (Equal/Before/ After/Compare/DaysUntil) without conversion churn. Callers that want a time.Time use the embedded field: `for d := range h.Range() { t := d.Time }`. Recorded as CL-11.

Example

ExampleHoliday_Range demonstrates the Go 1.23 range-over-func iterator on Holiday. The iterator yields one Date per calendar day in chronological order, inclusive on both endpoints.

h := openholidays.Holiday{
	StartDate: openholidays.NewDate(2025, time.January, 1),
	EndDate:   openholidays.NewDate(2025, time.January, 3),
}
for d := range h.Range() {
	fmt.Println(d)
}
Output:
2025-01-01
2025-01-02
2025-01-03

type HolidayType

type HolidayType string

HolidayType is the typed-string enum for Holiday.Type.

The six values below were verified against the live upstream OpenAPI spec on 2026-05-27. REQUIREMENTS.md TYPES-04 originally listed "Public, School, Bank, Observance" — the upstream actually returns "Public, Bank, Optional, School, BackToSchool, EndOfLessons" and never returns "Observance". The CL-04 scope clarification (recorded in PROJECT.md Key Decisions by Plan 06) ratifies shipping all six real values and dropping the spurious "Observance".

const (
	// HolidayTypePublic is a public (statutory) holiday.
	HolidayTypePublic HolidayType = "Public"
	// HolidayTypeBank is a bank holiday (banking-sector observance).
	HolidayTypeBank HolidayType = "Bank"
	// HolidayTypeOptional is an optional / discretionary holiday.
	HolidayTypeOptional HolidayType = "Optional"
	// HolidayTypeSchool is a school holiday (e.g. Polish ferie zimowe).
	HolidayTypeSchool HolidayType = "School"
	// HolidayTypeBackToSchool marks the first instruction day of a term.
	HolidayTypeBackToSchool HolidayType = "BackToSchool"
	// HolidayTypeEndOfLessons marks the last instruction day of a term.
	HolidayTypeEndOfLessons HolidayType = "EndOfLessons"
)

HolidayType wire-format constants. Identifiers are PascalCase; values are the exact strings the upstream returns (also PascalCase).

func (HolidayType) IsKnown

func (t HolidayType) IsKnown() bool

IsKnown reports whether t matches one of the six HolidayType constants declared by this package.

HolidayType is a typed-string alias, so upstream is free to return values outside the documented set (schema drift, new enum values added without spec update). The default lenient decoder accepts unknown values and the opt-in strict decoder surfaces unknown *fields* but not unknown enum *values* — both decoders flow unknown HolidayType values through unchanged. Callers that branch on Holiday.Type SHOULD gate the branch on IsKnown to make the unknown-value path explicit:

if h.Type.IsKnown() {
    switch h.Type { ... }
} else {
    // log / warn / treat as opaque
}

The check is O(1) (closed switch over six constants); no map allocation.

Example

ExampleHolidayType_IsKnown demonstrates the closed-set membership check for HolidayType — callers SHOULD gate a switch on Holiday.Type with IsKnown so the unknown-value path (upstream schema drift) is explicit.

fmt.Println(openholidays.HolidayType("Public").IsKnown(), openholidays.HolidayType("Bogus").IsKnown())
Output:
true false

type Language

type Language struct {
	// IsoCode is the ISO 639-1 two-letter language code (uppercase).
	IsoCode string `json:"isoCode"`
	// Name is the per-language localized language name (e.g. one entry
	// per language the API can describe the language in).
	Name []LocalizedText `json:"name"`
}

Language is the response shape for /Languages returned by the upstream OpenHolidays API. Use Language.NameFor to look up the localized language name for a given language code.

func (Language) NameFor

func (l Language) NameFor(lang string) (string, bool)

NameFor returns the localized language name for the given ISO 639-1 language code and reports whether a matching entry was found. See Country.NameFor for the matching semantics.

type LanguagesRequest

type LanguagesRequest struct {
	// LanguageIsoCode is an optional ISO 639-1 language filter.
	LanguageIsoCode string
}

LanguagesRequest carries the optional filter exposed by the upstream /Languages endpoint. The zero value (LanguagesRequest{}) requests the upstream's unfiltered list and is the recommended call shape when no filter is needed.

Fields:

  • LanguageIsoCode is an optional ISO 639-1 two-letter language code (case-insensitive; canonicalized to uppercase before being sent on the wire). When non-empty, the request includes the corresponding languageIsoCode query parameter and the upstream returns only the localized Language.Name entries in that language. When empty, the parameter is omitted and the upstream returns all localized names for each Language (D-54 / D-55 / CL-13).

Validation: non-empty LanguageIsoCode is validated client-side via validateLanguage (D-56) before any HTTP request is made; a malformed value returns an error wrapping ErrInvalidLanguage without reaching the network.

type LocalizedText

type LocalizedText struct {
	// Language is the ISO 639-1 two-letter language code (e.g. "pl", "en").
	Language string `json:"language"`
	// Text is the localized text in the named language.
	Text string `json:"text"`
}

LocalizedText is a (language, text) pair returned by the upstream API in every localized-string field (Holiday.Name, Holiday.Comment, Country.Name, Language.Name, Subdivision.Name, Subdivision.Category, Subdivision.Comment).

Both fields are required by the upstream schema (minLength: 1 each).

type MemoryCache

type MemoryCache struct {
	// contains filtered or unexported fields
}

MemoryCache is the default in-memory TTL cache returned by NewMemoryCache (D-81). The backing storage is map[string]entry under sync.RWMutex — safe for concurrent use from any goroutine (CLIENT-07 / Pitfall CACHE-4).

Instances are constructed via NewMemoryCache (or newMemoryCacheWithClock inside tests) and stopped via Close. The zero value is NOT usable — fields are populated by the constructor; copying a MemoryCache by value is not supported (sync.RWMutex and sync.Once trigger the standard go vet copy-lock warning).

Lifecycle:

  • construction: NewMemoryCache or newMemoryCacheWithClock allocates the entries map and a sweeper context; NO goroutine is started yet.
  • first Put: startOnce.Do(startSweeper) spawns exactly one goroutine (D-84 lazy start).
  • subsequent Put / Get: O(1) under the mutex.
  • Close: cancels the sweeper context, briefly waits on the sweepDone channel (1 ms cap), returns nil. Idempotent under closeOnce (D-85).

func NewMemoryCache

func NewMemoryCache(ttl time.Duration) *MemoryCache

NewMemoryCache constructs a *MemoryCache backed by an in-memory map with the supplied TTL (D-79 / D-81). The cache uses time.Now as its clock; for fake-clock tests, use newMemoryCacheWithClock through a WithCacheBackend(...) wiring (D-86 documents the compromise — options run BEFORE Client construction, so WithCache(ttl) cannot pick up a Client-side nowFunc retroactively).

The sweeper goroutine starts lazily on the first successful Put (D-84); a constructed-but-unused MemoryCache costs zero goroutines. Close is idempotent (D-85) and safe to call from any goroutine concurrently — the documented v1 cleanup idiom is defer client.Close().

Caller contract on ttl (WR-02): callers MUST supply a positive ttl. A non-positive ttl (ttl <= 0) produces a constructed-but-useless cache — every Put stores an entry whose expiresAt is at or before now, so every subsequent Get returns (nil, false), AND the sweeper still spawns on first Put (wasting one goroutine for the cache's lifetime). The WithCache(ttl) option correctly rejects ttl <= 0 (D-80); callers using NewMemoryCache directly via WithCacheBackend(NewMemoryCache(ttl)) must validate ttl themselves. NewMemoryCache does not panic on ttl <= 0 to preserve the library contract that constructors never error.

func (*MemoryCache) Close

func (m *MemoryCache) Close() error

Close cancels the sweeper context and waits briefly for the sweeper goroutine to exit. Idempotent under closeOnce (D-85): concurrent Close calls from any number of goroutines all return nil without racing.

The 1 ms timeout on the sweepDone wait is the deliberate cap: if the sweeper is in the middle of an evict() call holding the write lock, Close still returns within 1 ms rather than blocking indefinitely. The sweeper will exit shortly thereafter when it observes ctx.Done. This is best-effort cleanup per the Cache interface contract — Close's promise is "no further sweeper work after I return", not "the goroutine is definitely off the runtime queue".

If the sweeper was never started (no Put was ever called), sweepDone stays open forever and the select takes the time.After branch — that's the intended path for an idle cache.

func (*MemoryCache) Get

func (m *MemoryCache) Get(key string) ([]byte, bool)

Get returns the cached bytes for key and true on a hit, or (nil, false) on a miss or expired entry (D-81 lazy-expiration-on-read). Safe for concurrent use.

The RLock path is the hot path: a hit returns a defensive copy of the stored slice without acquiring the write lock. Stale entries are NOT deleted on Get — deletion is the sweeper's responsibility — but they are reported as a miss so callers observe the expected post-TTL behavior immediately.

IN-05: the returned slice is a defensive copy of the internal byte buffer, NOT a reference. Callers may safely mutate or retain the returned slice without corrupting cache state. The copy cost for the typical 50-holiday JSON payload (<10 KiB) is negligible relative to the alternative footgun where a caller mutating the returned slice silently corrupts every subsequent cache read. This guarantee is part of the Cache interface contract (config.go).

func (*MemoryCache) Put

func (m *MemoryCache) Put(key string, value []byte)

Put stores value under key with an expiresAt of now + ttl. The first successful Put lazily starts the sweeper goroutine (D-84) — a constructed-but-unused MemoryCache spawns no goroutines until something is stored in it.

Replacing an existing entry at key is supported and refreshes the TTL. Safe for concurrent use.

IN-05: Put stores a defensive copy of value, NOT a reference. Callers may safely mutate or retain the supplied slice after Put returns without corrupting cache state. This guarantee mirrors the Get-side copy contract and is part of the Cache interface (config.go).

type Option

type Option func(*clientConfig)

Option configures a Client at construction time. Options compose via NewClient: each Option mutates a private *clientConfig builder, and the final *Client is constructed from that builder. After NewClient returns, the Client is immutable; further Option calls on a constructed Client have no effect by design (no setter exists).

func WithBaseURL

func WithBaseURL(u string) Option

WithBaseURL overrides the default base URL. A trailing slash, if present, is trimmed so endpoint paths (always beginning with "/") concatenate cleanly. Multiple trailing slashes are trimmed too.

WithBaseURL("") is treated as "use the default" — the default base URL applied by defaultConfig is left in place. Inputs that collapse to an empty string after trailing-slash trimming (e.g. "/", "//", "///") are also treated as "use the default" so callers reading base URLs from environment variables that default to "/" do not silently land in an unusable state where downstream HTTP calls fail with opaque "unsupported protocol scheme" errors far from the misconfiguration (WR-01 follow-up).

Callers wanting to point the SDK at a mirror should pass the mirror's URL here (D-36 explicitly rejects environment-variable overrides; WithBaseURL is the supported extension point).

func WithCache

func WithCache(ttl time.Duration) Option

WithCache enables the default in-memory TTL cache with the supplied TTL (D-79 / D-80 / D-83 / RESIL-06..09). When ttl > 0, the option constructs a *MemoryCache via newMemoryCacheWithClock(ttl, time.Now) and stores it on the internal Cache field; the Client's RoundTripper chain inserts a cacheTransport layer that consults this cache for the three reference endpoints (/Countries, /Languages, /Subdivisions). Holiday endpoints (/PublicHolidays, /SchoolHolidays) bypass the cache by default — RESIL-07 / temporal-data trap.

Cache-hit semantics:

  • Hit: cacheTransport returns a synthetic 200 OK response with the cached bytes; the downstream decoder runs against those bytes, including strict-mode (D-93). The req.Context() of the synthetic response carries CacheHitContextKey == true so consumers can detect cache hits in WithRequestHook.
  • Miss: cacheTransport forwards to the next RoundTripper; on success (err == nil && status == 200) the response bytes are cached for the configured TTL (Pitfall CACHE-1).

ttl <= 0 is treated as DISABLED (defensive symmetry with WithTimeout(0) per D-80). The default Client has NO cache — opt in via WithCache or WithCacheBackend.

Clock seam (D-86): WithCache uses time.Now literally because options run BEFORE Client construction; the Client's internal nowFunc cannot be picked up retroactively by the cache. Tests that need a fake clock route through WithCacheBackend(newMemoryCacheWithClock(ttl, fc.Now)) instead.

Lifecycle: Client.Close calls cache.Close (D-85), which stops the sweeper goroutine. Consumers MUST defer client.Close() to avoid leaking the sweeper (Pitfall CONC-2). For a constructed-but-never-used cache, Close is still safe and returns nil (no sweeper was ever started — D-84 lazy).

func WithCacheBackend

func WithCacheBackend(c Cache) Option

WithCacheBackend supplies a custom Cache implementation; supersedes any prior WithCache(ttl) per the D-80 last-wins functional-options convention. A nil argument is a no-op (mirrors WithHTTPClient(nil)).

When the caller supplies a backend, the Client does NOT own the backend's goroutines or resources — Client.Close still calls c.Close() per the interface contract, but the backend is responsible for its own lifecycle. Pitfall CACHE-4 (read-during-evict race) only applies to the default MemoryCache; custom backends MUST implement their own thread-safety because cacheTransport does NOT serialize access to Get/Put/Close (the RoundTripper is a pure pass-through).

Use case: integration tests can swap in a deterministic clock-driven MemoryCache via newMemoryCacheWithClock(ttl, fc.Now) (TEST-06); future consumers can implement Redis-backed or LRU-backed caches without a library change.

func WithHTTPClient

func WithHTTPClient(c *http.Client) Option

WithHTTPClient supplies a pre-configured *http.Client. The SDK shallow-copies the supplied client inside composeHTTPClient and replaces the copy's Transport with the SDK's RoundTripper chain (D-37 / Pitfall HTTP-1); caller mutations of the supplied *http.Client after NewClient returns therefore do not affect the SDK.

A nil argument is a no-op — the SDK retains its zero-valued default *http.Client. To suppress all SDK middleware, supply an *http.Client whose Transport is set to a caller-owned http.RoundTripper and accept that buildTransport will wrap it with the documented chain.

NOTE: setting Timeout on the supplied *http.Client may cause spurious "context canceled" errors on body close (see golang/go#49521); prefer WithTimeout(d) to bound per-request duration via context (D-26).

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger injects a structured logger. The SDK emits one slog.LevelDebug record per HTTP round trip via loggingTransport (transport.go) with the six OBS-02 fields (method, path, status, duration_ms, attempt, bytes_in).

A nil argument falls back to slog.Default() (D-39). The library NEVER mutates the process-wide default logger — this preserves the consuming application's global logger configuration.

func WithMaxRetryWait

func WithMaxRetryWait(d time.Duration) Option

WithMaxRetryWait sets the per-attempt sleep ceiling applied by the retry loop (D-74 default 60s). A non-positive duration falls back to defaultMaxRetryWait (60s) per D-74 — calling WithMaxRetryWait(0) does NOT disable the cap.

Note (IN-03 / Pitfall): this is asymmetric with WithTimeout(0) (which means "no SDK-imposed timeout") and WithRetry(0, _) (which means "retry disabled"). The asymmetry is intentional and documented at D-74: a per-attempt sleep without an upper bound exposes the SDK to hostile-upstream attacks (T-04-05 — Retry-After: 999999999 holding a goroutine indefinitely), so "no cap" is not a supported configuration. Consumers wanting an effectively-unbounded cap should pass a very large duration (e.g. WithMaxRetryWait(24*time.Hour)) explicitly.

The ceiling applies to each individual sleep, NOT a separate cumulative retry budget (CONTEXT.md `<specifics>` 5). However, this does NOT mean the SDK is unbounded across retries by default: WithTimeout supplies the cumulative ceiling. Under the default WithTimeout(15*time.Second), the per-request context.WithTimeout applied in doJSONGet bounds the whole request — every c.http.Do attempt AND every inter-attempt sleep — to 15s total, so the retry sequence is in practice capped by WithTimeout. Callers who want truly unbounded retries must opt out explicitly with WithTimeout(0); with both WithTimeout(0) and (e.g.) five attempts at a 60s cap, the worst case is ~5 minutes total. Consumers wanting a different cumulative cap can supply ctx.WithTimeout(ctx, totalBudget) themselves or pass WithTimeout(totalBudget) — the SDK does not expose a SEPARATE cumulative-retry-budget knob beyond these (deferred per CONTEXT.md `<deferred>`).

The cap also bounds Retry-After promotion: a hostile upstream returning Retry-After: 999999999 cannot hold the request for the lifetime of the process (threat T-04-05) because computeBackoff applies min(retryAfter, maxWait) per D-76.

Note that calling WithMaxRetryWait alone (without WithRetry) has no observable effect — retry is opt-in, and the cap is only consulted when the retry loop runs (maxAttempts > 0). The intended idiom is to pass both options together when finer control over the cap is needed:

c := NewClient(
    WithRetry(5, 100*time.Millisecond),
    WithMaxRetryWait(10*time.Second),
)

func WithRequestHook

func WithRequestHook(fn RequestHookFunc) Option

WithRequestHook supplies an observability hook function invoked after every HTTP round trip the Client performs (D-87 / D-88 / D-89 / D-90 / TRANS-05). The hook receives the (*http.Request, *http.Response, error) triple produced by the RoundTripper chain. Use it to wire metrics counters, distributed-tracing spans, or per-request audit logs into the SDK without modifying it — the hook is the single observability seam the library exposes.

Hook contract (D-87..D-90):

  • Fires AFTER every real HTTP round trip — including each retry attempt (retry lives in doJSONGet per RESIL-05, so each c.http.Do dispatch re-enters the chain; a 429→500→200 sequence triggers three hook invocations).
  • Fires on cache-hit synthetic responses too (D-88). cacheTransport stores cached bytes for /Countries, /Languages, /Subdivisions; on a hit it builds a synthetic *http.Response and the hook above sees it. Distinguish hits from real round trips via req.Context().Value(openholidays.CacheHitContextKey) — the value is the untyped boolean true on a hit, and absent (nil) on a miss.
  • Does NOT fire on decode errors. JSON decoding runs in doJSONGet AFTER the RoundTripper chain returns; a successful HTTP round trip whose body fails decode produces exactly one hook invocation (for the HTTP layer success), not two.
  • Does NOT fire on pre-HTTP failures (validateCountry rejecting an empty CountryIsoCode, validateDateRange rejecting a backwards window, etc.). No HTTP attempt → no hook.

Synchronous-only contract (D-90 / Pitfall CONC-2): the hook runs on the calling goroutine's stack. If you need asynchronous behavior, spawn a goroutine inside your hook — but YOU own the goroutine and any leak. Async-hook support (background queue with bounded buffer) is explicitly deferred (CONTEXT.md `<deferred>`).

Panic propagation (D-90 / mirrors stdlib http.Handler): a panicking hook propagates the panic to the caller. The library does NOT use defer/recover — silent recovery would hide bugs in consumer hooks. Consumers wanting recovery wrap their hook body with their own defer/recover; the library will never do it for them.

Body invariant (Pitfall LOG-1 / OBS-01): the hook MUST NOT read resp.Body — doing so depletes bytes before the downstream decoder runs in doJSONGet. The hook also MUST NOT log resp.Body content above slog.LevelDebug because that would leak payload data into operator logs (PROJECT.md). Log method, URL, status, duration, attempt counters, trace-context — never the body content.

Nil-safe contract (D-88): on a transport-level failure (DNS error, TCP reset, ctx cancel during request body write) the hook receives (req, nil, err). Implementations MUST nil-check resp before accessing any field on it.

Chain placement (D-89): hookTransport is the OUTERMOST RoundTripper in the chain, so it observes EVERY round trip the chain performs:

req → hookTransport → cacheTransport → loggingTransport →
      headerTransport → underlying

A nil fn is a no-op (mirrors WithHTTPClient(nil)) — the default Client has no hook, and buildTransport elides the hookTransport layer entirely when cfg.hook is nil so there is zero overhead for callers not using observability.

func WithRetry

func WithRetry(maxAttempts int, baseDelay time.Duration) Option

WithRetry enables retry with exponential backoff + full jitter for every endpoint method on the constructed Client (D-73 / D-74 / D-75 / D-76 / D-77 / RESIL-01..05). Retry is OFF by default — calling WithRetry is the only way to enable it.

Arguments:

  • maxAttempts: maximum number of c.http.Do invocations inside the retry loop. <=0 is interpreted as DISABLED (the loop runs exactly once and surfaces the first response/error verbatim — defensive symmetry with WithTimeout(0) per D-74).
  • baseDelay: base unit for exponential backoff. <=0 falls back to defaultBaseDelay (250ms) per D-74.

When WithMaxRetryWait is NOT also called, WithRetry sets the per- attempt sleep ceiling to defaultMaxRetryWait (60s) per D-74 so a caller that opts in to retry with a single line never accidentally disables the cap. If both WithRetry and WithMaxRetryWait are called, last-wins applies per the functional-options convention.

Retryable conditions (D-75):

Retry-After handling (D-76): when an upstream response carries a Retry-After header (integer seconds or RFC 7231 HTTP-date), the per-attempt sleep is max(retryAfter, jitterDelay) capped at the per-attempt ceiling. Past-dated HTTP-dates are rejected (Pitfall 9 / threat T-04-06) so backoff never collapses to zero. When the header is absent or unparseable, the sleep is full-jitter exponential: uniform random in [0, baseDelay << attempt) capped at the per- attempt ceiling.

Placement (D-77 + RESIL-05): the retry loop lives inside doJSONGet (the endpoint layer), NOT a RoundTripper. Consumers who supply their own retrying *http.Client via WithHTTPClient therefore do NOT see double-firing of attempts. Retry is an opt-in SDK feature; callers wanting it disable in their custom transport (or just don't call WithRetry) will see exactly one round trip per endpoint method.

The per-attempt ceiling bounds each individual sleep (default 60s), NOT the cumulative retry budget — five attempts with 60s cap can still take ~5 min total. Consumers wanting a cumulative cap supply ctx.WithTimeout(ctx, totalBudget); the SDK's retry loop is ctx-aware (Pitfall RETRY-3) and returns ctx.Err() within ≤ 100 ms of caller cancellation (CLIENT-09 / RESIL-04).

func WithStrictDecoding

func WithStrictDecoding(strict bool) Option

WithStrictDecoding enables strict JSON decoding via json.Decoder.DisallowUnknownFields (D-91 / D-92 / CL-15). When strict is true, every JSON response decoded by the SDK rejects payloads containing fields absent from the destination Go struct — useful for surfacing upstream schema drift loudly during integration tests or in canary deployments.

Strict-decoding is OFF by default (Pitfall JSON-1): the upstream OpenHolidays API adds fields routinely, and silent rejection would break consumers on every benign schema bump. Opt in only when the consumer wants the loud-fail behavior.

The flag is immutable after NewClient. No per-call override and no runtime toggle exist by design — toggling at runtime would let cached bytes decoded under one mode surface as a strict-failure after the toggle (D-93). Consumers wanting "cache lenient + fresh strict" must instantiate two Clients.

false is stored verbatim (no defensive special-case) — matches the WithTimeout verbatim convention.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the per-request timeout applied via context.WithTimeout inside every endpoint method (D-26 / D-27). The default is fifteen seconds (CLIENT-06 / D-28).

A zero duration disables the SDK-imposed timeout; the caller's ctx becomes the only deadline. The value is stored verbatim (negative durations are accepted as-is per D-28 "verbatim" — the endpoint methods interpret a non-positive value as "no SDK timeout").

WithTimeout does NOT mutate cfg.httpClient.Timeout (D-26): setting the stdlib Client.Timeout is known to cause spurious "context canceled" errors on response-body close (golang/go#49521), so the SDK uses ctx timeouts exclusively.

func WithUserAgent

func WithUserAgent(s string) Option

WithUserAgent overrides the default User-Agent header ("go-openholidays/<Version>") sent on every HTTP request.

An empty string is treated as "use the default" — the library never sends an empty User-Agent (D-38) because some CDNs reject empty-UA requests as bot traffic (Pitfall HTTP-5). To suppress the User-Agent entirely, the caller must supply a custom http.RoundTripper via WithHTTPClient.

type PublicHolidaysRequest

type PublicHolidaysRequest struct {
	// CountryIsoCode is the required ISO 3166-1 alpha-2 country code.
	CountryIsoCode string
	// ValidFrom is the required inclusive lower bound of the date window.
	ValidFrom Date
	// ValidTo is the required inclusive upper bound of the date window.
	ValidTo Date
	// LanguageIsoCode is an optional ISO 639-1 language filter.
	LanguageIsoCode string
	// SubdivisionCode is an optional subdivision-code filter, passed
	// through to the upstream verbatim with no client-side shape check.
	SubdivisionCode string
}

PublicHolidaysRequest carries the filters supported by the upstream /PublicHolidays endpoint. Fields mirror the upstream query parameters exactly (D-53 / CL-13): exposing every upstream-supported filter is the pattern every sibling Request struct in this phase follows.

Fields:

  • CountryIsoCode is the required ISO 3166-1 alpha-2 country code (case-insensitive; canonicalized to uppercase before being sent on the wire). Empty or malformed values return an error wrapping ErrInvalidCountry without dispatching the HTTP request (D-56).
  • ValidFrom is the required inclusive lower bound of the date window (YYYY-MM-DD; UTC midnight). Must be ≤ ValidTo and within 3 calendar years of ValidTo (validateDateRange / D-22 / VALID-02 / VALID-03).
  • ValidTo is the required inclusive upper bound of the date window (YYYY-MM-DD; UTC midnight). See ValidFrom.
  • LanguageIsoCode is an optional ISO 639-1 two-letter language code (case-insensitive; canonicalized to uppercase before being sent on the wire). When non-empty, restricts the localized Holiday.Name entries upstream returns to that language only. When empty, the parameter is omitted (D-55 / D-56) and the upstream returns all localized names.
  • SubdivisionCode is an optional administrative subdivision code (e.g. "PL-SL" for Śląskie). Shape-tolerant per D-56: no client-side validator runs; the value is passed through verbatim and the upstream is the authoritative source on which codes it accepts. When empty, the parameter is omitted.

type RegionalScope added in v1.0.0

type RegionalScope string

RegionalScope is the typed-string enum for Holiday.RegionalScope — the geographic breadth a holiday applies to.

Like HolidayType, RegionalScope is a typed-string alias: the upstream is free to emit values outside the documented set, which decode as-is. Use RegionalScope.IsKnown to test membership before branching on the value.

const (
	// RegionalScopeNational applies to the entire country.
	RegionalScopeNational RegionalScope = "National"
	// RegionalScopeRegional applies to one or more administrative subdivisions.
	RegionalScopeRegional RegionalScope = "Regional"
	// RegionalScopeLocal applies to a local area within a subdivision.
	RegionalScopeLocal RegionalScope = "Local"
)

RegionalScope wire-format constants (the closed value set per the upstream OpenAPI spec). Identifiers are PascalCase; values are the exact wire strings.

func (RegionalScope) IsKnown added in v1.0.0

func (s RegionalScope) IsKnown() bool

IsKnown reports whether s is one of the three documented RegionalScope constants. The upstream may emit other values; branch on IsKnown before relying on the value.

type RequestHookFunc

type RequestHookFunc func(*http.Request, *http.Response, error)

RequestHookFunc is the function shape accepted by WithRequestHook (Plan 05). It is invoked synchronously on the calling goroutine's stack after every HTTP round trip including cache-hit synthetic responses (D-88 / D-89). Panics propagate; consumers wrap with defer/recover if needed (mirrors stdlib http.Handler convention).

On a transport error resp is nil — implementations MUST nil-check. Hooks MUST NOT log resp.Body content above slog.LevelDebug (Pitfall LOG-1).

type SchoolHolidaysRequest

type SchoolHolidaysRequest struct {
	// CountryIsoCode is the required ISO 3166-1 alpha-2 country code.
	CountryIsoCode string
	// ValidFrom is the required inclusive lower bound of the date window.
	ValidFrom Date
	// ValidTo is the required inclusive upper bound of the date window.
	ValidTo Date
	// LanguageIsoCode is an optional ISO 639-1 language filter.
	LanguageIsoCode string
	// SubdivisionCode is an optional subdivision-code filter, passed
	// through to the upstream verbatim with no client-side shape check.
	SubdivisionCode string
	// GroupCode is an optional cohort/group-code filter (e.g. "A" / "B" /
	// "C" / "D" for Polish ferie cohorts), passed through verbatim with no
	// client-side shape check.
	GroupCode string
}

SchoolHolidaysRequest carries the filters supported by the upstream /SchoolHolidays endpoint. Fields mirror the upstream query parameters exactly (D-53 / CL-13): exposing every upstream-supported filter is the pattern every sibling Request struct in this phase follows. The struct mirrors PublicHolidaysRequest field-for-field and adds the optional GroupCode filter (D-54).

Fields:

  • CountryIsoCode is the required ISO 3166-1 alpha-2 country code (case-insensitive; canonicalized to uppercase before being sent on the wire). Empty or malformed values return an error wrapping ErrInvalidCountry without dispatching the HTTP request (D-56).
  • ValidFrom is the required inclusive lower bound of the date window (YYYY-MM-DD; UTC midnight). Must be ≤ ValidTo and within 3 calendar years of ValidTo (validateDateRange / D-22 / VALID-02 / VALID-03).
  • ValidTo is the required inclusive upper bound of the date window (YYYY-MM-DD; UTC midnight). See ValidFrom.
  • LanguageIsoCode is an optional ISO 639-1 two-letter language code (case-insensitive; canonicalized to uppercase before being sent on the wire). When non-empty, restricts the localized Holiday.Name entries upstream returns to that language only. When empty, the parameter is omitted (D-55 / D-56) and the upstream returns all localized names.
  • SubdivisionCode is an optional administrative subdivision code (e.g. "PL-SL" for Śląskie). Shape-tolerant per D-56: no client-side validator runs; the value is passed through verbatim and the upstream is the authoritative source on which codes it accepts. When empty, the parameter is omitted.
  • GroupCode is an optional cohort/group code filter (e.g. "A" / "B" / "C" / "D" for the four Polish ferie zimowe cohorts that stagger school-holiday windows across województwa). Shape-tolerant per D-56: no client-side validator runs; the value is passed through verbatim. When empty, the parameter is omitted (D-55). RESEARCH.md Assumption A2 notes that PL upstream responses do NOT echo a `groups` field on each entry — the GroupCode filter is therefore strictly a query-time filter, not a response-side predicate; callers that pass a non-empty GroupCode receive only the entries matching that cohort and there is no way to recover the cohort label from the response payload alone for PL.

type Subdivision

type Subdivision struct {
	// Code is the subdivision code (e.g. "PL-SL").
	Code string `json:"code"`
	// ShortName is the human-readable short name of the subdivision.
	ShortName string `json:"shortName"`
	// Name is the per-language localized full name of the subdivision.
	Name []LocalizedText `json:"name"`
	// Category is the per-language localized category label
	// (e.g. "voivodeship", "region").
	Category []LocalizedText `json:"category"`
	// OfficialLanguages lists the ISO 639-1 codes of the subdivision's
	// official languages.
	OfficialLanguages []string `json:"officialLanguages"`
	// IsoCode is the optional ISO 3166-2 code for the subdivision.
	// Nullable upstream; an empty value indicates the subdivision has no
	// assigned ISO 3166-2 code.
	IsoCode string `json:"isoCode,omitempty"`
	// Comment is optional per-language commentary on the subdivision.
	// Nullable upstream.
	Comment []LocalizedText `json:"comment,omitempty"`
	// Children is the recursive list of nested subdivisions (e.g. powiaty
	// inside a województwo). Nullable upstream.
	Children []Subdivision `json:"children,omitempty"`
	// Groups lists the group memberships of the subdivision (e.g. ferie
	// cohort A/B/C/D for Polish województwa). Nullable upstream.
	Groups []GroupRef `json:"groups,omitempty"`
}

Subdivision is the response shape for /Subdivisions returned by the upstream OpenHolidays API. Subdivisions can be recursive — Subdivision.Children references the same type (e.g. a Polish województwo may contain powiaty).

Use Subdivision.NameFor to look up the localized subdivision name for a given language code.

func (Subdivision) NameFor

func (s Subdivision) NameFor(lang string) (string, bool)

NameFor returns the localized subdivision name for the given ISO 639-1 language code and reports whether a matching entry was found. See Country.NameFor for the matching semantics.

type SubdivisionRef

type SubdivisionRef struct {
	// Code is the OpenHolidays subdivision code (its own scheme, NOT ISO
	// 3166-2; any ISO 3166-2 value lives in Subdivision.IsoCode). The two
	// schemes do not always agree: under the live API "PL-SL" resolves to
	// Świętokrzyskie, whereas ISO 3166-2 assigns PL-SL to Śląskie (verified
	// 2026-05-30 probe).
	Code string `json:"code"`
	// ShortName is the human-readable short name of the subdivision.
	ShortName string `json:"shortName"`
}

SubdivisionRef is a lightweight reference (code + short display name) embedded in Holiday.Subdivisions when a holiday applies only to specific administrative subdivisions (e.g. Polish województwa for ferie zimowe).

The upstream calls this shape SubdivisionReference; the library uses the shorter SubdivisionRef name per ARCHITECTURE.md naming guidance.

type SubdivisionsRequest

type SubdivisionsRequest struct {
	// CountryIsoCode is the required ISO 3166-1 alpha-2 country code.
	CountryIsoCode string
	// LanguageIsoCode is the optional ISO 639-1 language filter.
	LanguageIsoCode string
}

SubdivisionsRequest carries the inputs for the /Subdivisions endpoint.

Fields:

  • CountryIsoCode is REQUIRED. It is the ISO 3166-1 alpha-2 country code (case-insensitive; canonicalized to uppercase before being sent on the wire). The validator runs on every call; an empty or malformed value returns an error wrapping ErrInvalidCountry without reaching the network (D-56).

  • LanguageIsoCode is OPTIONAL. It is the ISO 639-1 two-letter language code (case-insensitive; canonicalized to uppercase before being sent on the wire). When non-empty, the upstream returns only the localized Subdivision.Name / Subdivision.Category / Subdivision.Comment entries in that language. When empty, the parameter is omitted and the upstream returns every supported language for each Subdivision (D-54 / D-55 / CL-13).

Validation: a non-empty LanguageIsoCode is validated client-side via validateLanguage (D-56) before any HTTP request is made; a malformed value returns an error wrapping ErrInvalidLanguage without reaching the network.

type TemporalScope added in v1.0.0

type TemporalScope string

TemporalScope is the typed-string enum for Holiday.TemporalScope — whether a holiday occupies the full day or half the day.

Like HolidayType, TemporalScope is a typed-string alias: the upstream is free to emit values outside the documented set, which decode as-is. Use TemporalScope.IsKnown to test membership before branching on the value.

const (
	// TemporalScopeFullDay marks a holiday occupying the full day.
	TemporalScopeFullDay TemporalScope = "FullDay"
	// TemporalScopeHalfDay marks a holiday occupying half the day.
	TemporalScopeHalfDay TemporalScope = "HalfDay"
)

TemporalScope wire-format constants (the closed value set per the upstream OpenAPI spec).

func (TemporalScope) IsKnown added in v1.0.0

func (s TemporalScope) IsKnown() bool

IsKnown reports whether s is one of the two documented TemporalScope constants. The upstream may emit other values; branch on IsKnown before relying on the value.

Directories

Path Synopsis
cmd
ohcli command
Command ohcli is the demo CLI for the github.com/egeek-tech/go-openholidays library.
Command ohcli is the demo CLI for the github.com/egeek-tech/go-openholidays library.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL