feat: add a Go SDK alongside the Python and Node.js ports - #119
Conversation
The port carries the same features as the other two, with the surface shaped for Go: a context on every request, errors instead of exceptions, Options instead of keyword arguments, and a zero value that means the default the other ports declare in their signatures. Two pieces have no counterpart in the sibling ports, because their HTTP clients provide them: - a cookie jar that honours the scope FR24 sets, so a cookie stored by www. is not replayed to cdn./api./data-live., and a single cookie can be dropped to shed load-balancer stickiness without losing the login - content decoding owned by the package (gzip, deflate, brotli), which is what makes the size budget enforceable before a body expands Parity is enforced rather than promised: ports_test.go reads the Python source and fails when the country list, the zones, the tracker config, the flight attributes or the client methods drift apart.
The offline suite gates pull requests and the live FR24 suite runs with retries, mirroring the Python and Node.js workflows. gofmt, go vet and staticcheck stand in for flake8/mypy and eslint/tsd, and a consumer module is built against the package so a broken public surface fails the build. Releases need no registry upload: for Go the tag is the release. Since the module lives in a subdirectory, the toolchain only sees tags carrying that prefix, so publish.yml now pushes go/vX.Y.Z alongside the release tag, and the version check covers all three ports. The labeler tells the Go tests apart from the package itself, which matters because Go keeps them in the same directory where python/ and nodejs/ keep a sibling tests/ folder.
The per-package READMEs now describe only their own port, and the project-wide pages list all three. The issue templates asked for a "Python Version", which was already wrong for Node.js.
The package readme now carries what the Python and Node.js ones do — install, basic usage, documentation link — and nothing else. The deeper material it held (entity constructors, client options, error handling, TLS impersonation, the differences table) moves to docs/go.md, where the equivalent Python and Node.js pages already live.
Each readme leads with its own workflow badge and then repeats the project-wide block, so the Go one was missing Pypi, Npm, Downloads and Frequency, and the other three were missing the Go reference and version. The project-wide pages listed only some of the workflow badges: the root readme had no Node.js one and the documentation home had neither Node.js nor Go.
Each registry badge now sits next to the version it needs — Pypi with Python, Npm with Node, the Go reference with Go — and the license moves up beside the workflow badges. The Node floor is the engines.node of nodejs/package.json.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical panics, timeout and retry handling, transport assumptions, and stale checksums remain, alongside moderate correctness issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a Go SDK alongside the existing Python and Node.js ports, with API parity, custom transport handling, tests, documentation, CI, and release support.
Changes:
- Adds the Go module, client API, entities, parsers, cookies, retries, and decompression.
- Adds generated data, parity tests, fixtures, examples, and development tooling.
- Integrates Go into documentation, CI, dependency updates, and releases.
Final review findings:
go/flightradarapi/api.go:398,587— Critical (1 vote): nilflightvalues can panic instead of returning the promised error.go/flightradarapi/flight.go:445— Moderate (2 votes): normalize missing orN/Aaircraft images to an empty slice.go/flightradarapi/flight.go:344— Moderate (2 votes): validate all filter keys before evaluating criteria.go/flightradarapi/parsers.go:225— Moderate (3 votes): reject trailing non-whitespace after JSON.go/flightradarapi/request.go:531— Moderate (2 votes): wrap transport errors withErrFlightRadar.go/flightradarapi/request.go:491— Critical (2 votes): negative timeouts must use the default timeout.go/flightradarapi/request.go:224— Critical (2 votes): preventJitteroverflow and retry-duration panics.go/flightradarapi/request.go:347— Critical (1 vote): avoid assuminghttp.DefaultTransporthas a concrete type.go/go.sum:3— Critical (4 votes): remove stalerandomstringchecksums.
File summaries
| File | Description |
|---|---|
README.md |
Documents Go support and badges. |
python/README.md |
Updates Python-specific documentation. |
nodejs/README.md |
Updates Node.js-specific documentation. |
mkdocs.yml |
Adds Go documentation navigation. |
go/README.md |
Documents Go installation and usage. |
go/Makefile |
Adds Go development, CI, and release targets. |
go/LICENSE |
Adds Go module licensing. |
go/go.sum |
Records dependency checksums. |
go/go.mod |
Defines the Go module and dependencies. |
go/flightradarapi/zones.go |
Provides generated zone data. |
go/flightradarapi/values.go |
Provides payload conversion helpers. |
go/flightradarapi/testdata/airports.json |
Supplies airport parser fixtures. |
go/flightradarapi/testdata/airlines.html |
Supplies airline parser fixtures. |
go/flightradarapi/snapshots_test.go |
Tests live response snapshots. |
go/flightradarapi/request.go |
Implements HTTP, retries, decoding, and response limits. |
go/flightradarapi/ports_test.go |
Enforces parity with Python sources. |
go/flightradarapi/parsers.go |
Parses API payloads. |
go/flightradarapi/parsers_test.go |
Tests parser behavior. |
go/flightradarapi/flighttrackerconfig.go |
Implements tracker configuration. |
go/flightradarapi/flight.go |
Implements flight entities and detail handling. |
go/flightradarapi/example_test.go |
Provides usage examples. |
go/flightradarapi/errors.go |
Defines SDK errors. |
go/flightradarapi/entity.go |
Implements positioned entities and distance logic. |
go/flightradarapi/entities_test.go |
Tests entity and filtering behavior. |
go/flightradarapi/doc.go |
Documents the package and API mapping. |
go/flightradarapi/countries.go |
Provides generated country constants. |
go/flightradarapi/core.go |
Defines endpoints and request headers. |
go/flightradarapi/cookies.go |
Implements scoped cookie handling. |
go/flightradarapi/cookies_test.go |
Tests cookie behavior. |
go/flightradarapi/airport.go |
Implements airport entities and parsing. |
docs/index.md |
Adds the Go SDK documentation overview. |
docs/go.md |
Documents Go API usage. |
CONTRIBUTING.md |
Adds Go contribution guidance. |
.gitignore |
Ignores Go artifacts and coverage output. |
.github/workflows/publish.yml |
Adds Go release tagging and version validation. |
.github/workflows/labeler.yml |
Adds Go-aware labels. |
.github/workflows/go-package.yml |
Adds Go build, test, lint, and integration CI. |
.github/ISSUE_TEMPLATE/questioning.md |
Updates question template options. |
.github/ISSUE_TEMPLATE/bug_report.md |
Updates bug-report template options. |
.github/dependabot.yml |
Adds Go dependency updates. |
Review details
Suppressed comments (9)
go/flightradarapi/api.go:494
- Ranging over a Go map does not preserve the feed's JSON object order, so
GetFlightscan return a different flight order on each call. Python and Node.js preserve the response order, and callers commonly rely on the returned list being stable; decode the feed with an order-preserving representation (and add an order assertion) before constructing this slice.
for flightID, info := range content {
if flightID == "" || flightID[0] < '0' || flightID[0] > '9' {
continue
go/flightradarapi/api.go:763
requestrejects every non-2xx status before returning aResponse, so a server-side login failure (401/403/500, etc.) exits as*StatusErrorat line 765 and never reaches thesuccesscheck. The Python and Node.js ports classify both non-2xx andsuccess:falseresponses asLoginError; allow/classify the login response before generic status handling soerrors.Is(err, ErrLogin)is consistent.
response, err := c.client.request(ctx, c.endpoints.userLogin, requestOptions{
headers: jsonHeaders,
data: data,
timeout: c.Timeout,
})
go/flightradarapi/api.go:74
newAPIClientinstallsbankRedirectCookieswhenHTTPClient.CheckRedirectis nil (request.go:426-428), so leaving this field unset is precisely how redirect cookies are banked. This comment says the opposite and may lead callers to provide a custom handler that silently disables cookie banking; correct the guidance.
// size budget stays enforceable. Leave CheckRedirect unset, or the cookies
// FR24 hands out on a redirect hop are not banked. Any Jar is ignored: this
// package renders the Cookie header itself.
go/flightradarapi/api.go:587
- For a logged-in caller, a nil
flightreaches this dereference and panics instead of returning an error. Validate the pointer before constructing the historical-data URL, as this public method otherwise exposes a crash path for a representable input.
response, err := c.client.request(ctx, c.endpoints.historicalDataURL(flight.ID, fileType, timestamp),
go/flightradarapi/request.go:405
json.Unmarshalmaps JSONnullto a nil map without an error, so this method returns(nil, nil)even though it promises to parse a JSON object. Callers that only checkerrcan therefore treat a null response as a successful empty payload; rejectcontent == nilbefore returning.
go/flightradarapi/request.go:511- Although URL parsing errors are wrapped above, an error from
http.NewRequestWithContextis returned raw here. This violates the package's documented guarantee that every error wrapsErrFlightRadar(doc.go:22) and makeserrors.Is(err, ErrFlightRadar)fail for request-construction failures; wrap the underlying error.
go/flightradarapi/request.go:551 - A response-body read failure is also returned raw, so callers lose the package-wide
ErrFlightRadarclassification for broken connections or failing readers. Wrap this error with the response URL and underlying cause.
go/flightradarapi/request.go:561 - Errors from
decodeBody(for example, malformed gzip/deflate/brotli data) escape without wrappingErrFlightRadar, contrary to the package and documentation contract. Wrap the decoder error while preserving any existing sentinel such asErrDecompressionLimit.
go/flightradarapi/request.go:441 - These request entry points return
runWithRetryerrors verbatim. Transport/read failures fromc.httpClient.Doandio.ReadAll, as well as cancellation during retry backoff, can therefore escape as raw*url.Error, I/O, or context errors even though the package documentation promises that every error wrapsErrFlightRadar; wrap non-package errors at this boundary while preserving the underlying error forerrors.Is/errors.Asin both entry points.
- Files reviewed: 41/43 changed files
- Comments generated: 8
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Eight findings from the review on the pull request, each reproduced before it was changed: - CheckInfo reported an unknown field only when Go's randomised map iteration put it before a criterion that fails: 26 of 200 runs on the same input. Every criterion is now validated before any is evaluated. - GetFlightDetails and GetHistoryData take a pointer, so nil is valid at compile time and panicked instead of returning an error. - A body with data spliced after the JSON was accepted, where the Python and Node.js parsers reject it: Decode reads the first value only. - Network failures did not wrap ErrFlightRadar, contradicting the taxonomy documented on that sentinel. They now wrap both it and the transport's own cause, which the retry policy still reads. - A negative Client.Timeout skipped the deadline entirely instead of falling back to the default the field documents. - New() panicked when the process had replaced http.DefaultTransport, which mocking libraries legitimately do. - SleepFor panicked on the largest Jitter a Duration can hold, and could wrap round when adding it to the delay. - Missing aircraft images stayed nil where the other ports default to an empty list, which changes both a CheckInfo comparison and the JSON.
There was a problem hiding this comment.
🟡 Changes recommended
Critical issues remain in cookie path matching, retry validation, the request-test assertion, and stale go.sum checksums; moderate issues remain in feed ordering and cancellation error wrapping.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (7)
Previously missed (2) — in code that hasn't changed since the last review.
go/flightradarapi/api.go:503
contentwas decoded into a Go map, whose iteration order is deliberately randomized. As a result, identical feed responses can produce flights in a different order on successive calls, unlike the ordered Python/Node.js results; preserve the feed's object order (or otherwise define a stable ordering) before building this slice.
for flightID, info := range content {
if flightID == "" || flightID[0] < '0' || flightID[0] > '9' {
continue
}
if row, ok := info.([]any); ok {
flights = append(flights, newFlight(flightID, row))
}
}
go/flightradarapi/request.go:287
- When cancellation happens during the retry backoff, this returns
ctx.Err()directly. That violates the package's documented guarantee that every returned error wrapsErrFlightRadar, so callers cannot consistently classify this failure witherrors.Is(err, ErrFlightRadar)even though transport cancellations are wrapped.
.github/workflows/go-package.yml:13
ports_test.goreads the Python sources to enforce parity, but neither trigger includespython/**. A Python change can therefore alter the source of truth without running this workflow, allowing generated Go data or API drift to go undetected; include the Python tree in both path filters.
paths:
- 'go/**'
- '.github/workflows/go-package.yml'
pull_request:
paths:
- 'go/**'
- '.github/workflows/go-package.yml'
go/flightradarapi/api.go:774
- The request layer turns every non-2xx response into a
StatusErrorbefore this method can inspect the login payload, so ordinary authentication failures such as HTTP 400/401 cannot become the documentedLoginErrorand callers cannot matchErrLogin. Allow the login response to be parsed for failure statuses, while still requiring both a 2xx status andsuccessfor a successful login.
response, err := c.client.request(ctx, c.endpoints.userLogin, requestOptions{
headers: jsonHeaders,
data: data,
timeout: c.Timeout,
})
if err != nil {
return err
go/flightradarapi/request.go:684
Header.Getreturns only the first value whenContent-Encodingis sent in multiple header fields. That is equivalent to a comma-separated encoding list on the wire, but this loop then decodes only one layer and can leave the body compressed, despite the stated support for stacked encodings. JoinHeader.Values("Content-Encoding")before splitting.
go/flightradarapi/request.go:428- Unmarshalling JSON
nullinto a map succeeds and leavescontent == nil, soResponse.JSONreturns(nil, nil)even though its contract is to parse a JSON object; plain endpoints can therefore report success with no object. Reject a nil map after unmarshalling with anErrFlightRadar-wrapped error.
go/flightradarapi/request.go:413 - HTTP media types are case-insensitive, but this exact-case check rejects a valid
Content-Type: Application/JSONresponse before parsing it. Normalize or parse the media type case-insensitively so JSON endpoints do not fail based on header casing.
- Files reviewed: 41/43 changed files
- Comments generated: 3
- Review effort level: Lite
A connection dropped partway through a response was classified as permanent, because the body is read after Do returns and so carries no *url.Error of its own: against a server that promises 5000 bytes and hangs up after 6, a policy asking for three attempts made one. The Python port retries the same failure, where curl_cffi reads the body inside the call. The read failure is now wrapped the way the transport wraps its own. Two readers were narrower than they read: nativeNumber covered every numeric kind except float64, so a defined type over float64 was refused where one over int64 worked, and getString ignored json.Number, which this package itself produces when it decodes with UseNumber.
The jar collapsed same-named cookies to one, so a request to /data/... carried the root token where FR24 had scoped a different value to that path. RFC 6265 5.4 asks for every match, longest path first and oldest first among equal paths, which is also what the cookie jar behind the Python port does. get() still answers with the newest re-issue: it says which token is current, not what to put on the wire. A negative BaseDelay reached the overflow guard added with the last round of fixes and came back as the longest sleep a Duration can hold — 292 years before the first retry. The zero BaseDelay that turns into NaN once the doubling overflows landed in the same place.
GetBounds was asserted twice with the same call and different constants, and the "box surrounds the point" test was subsumed by the one comparing all four values against the numbers the Python suite pins. Its two assertions moved into the survivor rather than being dropped: they are what catches an expected-value fixture updated the wrong way. Reordering the fields and "fixing" the fixture to match leaves the exact comparison passing and fails on the shape.
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved dependency, error-wrapping, input-validation, and documentation issues remain.
Review details
Suppressed comments (4)
Previously missed (4) — in code that hasn't changed since the last review.
go/flightradarapi/request.go:292
- When a request is canceled while waiting between retry attempts, this returns
context.Canceled/context.DeadlineExceededdirectly, soerrors.Is(err, ErrFlightRadar)is false even though the package documentation says every returned error wrapsErrFlightRadar. Wrap the context error with the package sentinel here, consistent with the other request failure paths.
go/flightradarapi/api.go:224 - The documentation defines only zero as the default, but this also silently converts any negative
flightLimitorpageinto a valid request for the default/first page. That hides invalid caller input and diverges from the Python and Node.js methods, which forward an explicitly supplied negative value and let the API reject it; preserve negative inputs (or return a validation error) and default only on zero.
if flightLimit <= 0 {
flightLimit = defaultFlightLimit
}
if page <= 0 {
page = 1
go/flightradarapi/api.go:655
- As with
GetAirportDetails, the Go docs say zero selects the default, but a negativelimitis silently changed to 50 instead of being preserved/rejected as an explicit invalid argument. This masks caller errors and does not match the sibling SDKs; default only whenlimit == 0(or return a validation error for negatives).
if limit <= 0 {
limit = defaultSearchLimit
docs/go.md:25
- This setup example uses
time.Second, but its only shown import is the SDK package, so copying the block produces an undefinedtimecompile error. Add the standard-library import (or omit the options example) so the documented basic usage is self-contained.
```go
import "github.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi"
client := flightradarapi.New()
// Every field's zero value means the default, so set only what you need.
client = flightradarapi.New(flightradarapi.Options{
Timeout: 10 * time.Second, // default: 30s
- Files reviewed: 41/43 changed files
- Comments generated: 0 new
- Review effort level: Lite
GetFlights walked a Go map, whose iteration order is randomised, so the same feed answered in a different order on every call where the Python and Node.js ports keep the order FR24 sent. The keys are now read from the body itself. Four smaller ones, each reproduced first: - a body of "null" unmarshalled into a nil map with no error, so every key read as missing instead of the caller seeing the failure - Content-Type was matched case-sensitively, though a media type is not - Content-Encoding split across header fields decoded only its first layer and left the body compressed - cancellation during the retry backoff returned the bare context error, outside the taxonomy every other failure path follows A negative flightLimit, page or limit is no longer rewritten as the default: only zero selects it, and anything else goes to FR24 as given, which is what the sibling ports do. The workflow now also runs when python/FlightRadarAPI changes, since ports_test.go reads those files to detect drift and could not see a change that never triggered it.
SetFlightTrackerConfig copied the struct it was given without looking at
it. Go's zero value is the empty string where the Python dataclass
carries a default, so a literal {Limit: "10"} sent the feed
adsb=&air=&estimated=&faa=… — while the very same empty value was
rejected when it arrived through the values map. Unset fields now take
the default and every field is validated, so the two paths agree.
RetryPolicy read a zero MaxDelay as "no cap at all", which let a struct
literal climb to a four-minute sleep where the Python constructor caps
at thirty seconds. Zero now means the default the other ports declare,
for BaseDelay as well, so &RetryPolicy{MaxAttempts: 5} backs off exactly
like NewRetryPolicy(5). A zero Jitter still means none: a deterministic
test wants to be able to ask for that.
A cookie with Domain=localhost sent from localhost was discarded by the
guard against a bare TLD, though RFC 6265 5.3.5 allows a domain that is
the host itself.
The header was written out while its comment claimed it came from the decoder table, so dropping a decoder would have left the client asking for an encoding it can no longer read — and the body would come back compressed, parsed as garbage rather than failing. It is now built from the table, with the order kept apart as the one part that is a choice. staticcheck and govulncheck ran as @latest inside a required CI step, the only tools in these workflows not pinned, so an upstream release could turn the build red on a tree nobody touched. Both are pinned in the Makefile, which the workflow now calls instead of repeating the command. Two comments had drifted from their code: the one on matching still described the one-cookie-per-name behaviour removed in 56c4fdf, and GetAirlineLogo carried an unreachable 5xx branch that read as though a server error fell through to the alternative URL.
Adds a third SDK under
go/, importable asgithub.com/JeanExtreme002/FlightRadarAPI/go/flightradarapi.Same features, Go-shaped surface
Every method of
FlightRadar24APIhas a counterpart, with the sameparameters. What differs is the shape:
new FlightRadar24API({timeout, maxWorkers})New(Options{Timeout: ..., MaxWorkers: ...})get_flights(airline, bounds, ...)GetFlights(ctx, FlightSearch{...})check_info(min_altitude=6700)CheckInfo(map[string]any{"min_altitude": 6700})Countries.BRAZILCountryBrazil, withAllCountries()to enumerateflight_limit=100)errorvalues wrappingErrFlightRadarcontext.Contexton every requestThe package doc opens with this mapping, so someone arriving from the
Python or Node.js docs finds where each name went.
Parity is enforced, not promised
ports_test.goreads the Python source and fails when the two drift:Countryconstants against theCountriesenumzones.pyFlightTrackerConfigagainst the dataclassFlightexposes, so a portedcheck_infofilter keeps workingFlightRadar24API, by reflectioncountries.goandzones.goare generated from the Python source andsay so in their header.
Written from scratch where the sibling ports lean on their HTTP client
www.is not replayed to
cdn./api./data-live.,Path/Secure/expiryare respected, cookies handed out on a redirect hop are banked, and a
single cookie can be dropped to shed load-balancer stickiness without
losing the login.
deflate in both shapes, brotli), which is what makes the size budget
enforceable before a body expands rather than after.
Testing
214 tests, 93% coverage, race detector clean. The offline suite gates
PRs; a live FR24 suite runs behind
-tags integrationand passes againstproduction.
gofmt,go vetandstaticcheckstand in for the lintersthe other two ports run.
Releasing
Go needs no registry upload — the tag is the release. Because the module
lives in a subdirectory, the toolchain only sees tags carrying that
prefix, so
publish.ymlnow pushesgo/vX.Y.Zalongside the releasetag, and the version check covers all three ports.
One open question
The live FR24 step keeps the
continue-on-error: ${{ github.event_name == 'push' }}line copied from the Python and Node.js workflows, which makesthat suite a hard gate on pull requests. An FR24 outage can therefore fail
a docs-only PR. Changing it is a repo-wide policy call, so it is left as
is here rather than making the Go workflow the odd one out.