From a736734cc15734451826d80b2df67f2e448ef04d Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Tue, 4 Aug 2026 13:12:29 -0400 Subject: [PATCH] Explain 403 as entitlement and return lookup tables whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paper cuts from #8. A tier-gated endpoint answers with a bare {"message":"Forbidden"}, which is indistinguishable from a credentials problem — and because every other command keeps working on the same key, the natural conclusion is that auth broke. Print a hint under 403 saying it is entitlement, not authentication, and contrast it with the 401 a rejected key actually returns. 401 gets its own hint pointing at `quartr auth show`. The "undocumented typeId 25" turns out to be the default page size: the document-type catalog has 46 rows and the CLI asked for 10, so 25 (shareholder letter) and 46 (DEFM14A) were simply past the cutoff — the API has had them all along. Rather than hand-maintain a static table that would drift, mark the two lookup tables as full catalogs and page them to exhaustion by default. An explicit --limit or --cursor opts back out. README gains a table of the common ids for quick reference, labelled as a convenience with the lookup commands as the source of truth. Closes #8 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 37 +++++++++++++++ internal/cli/app.go | 3 ++ internal/cli/cli_test.go | 94 +++++++++++++++++++++++++++++++++++++++ internal/cli/errors.go | 33 +++++++++++++- internal/cli/handlers.go | 5 +++ internal/cli/help.go | 8 ++++ internal/cli/resources.go | 22 +++++---- 7 files changed, 193 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3d984fc..e5e9ec2 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,43 @@ quartr transcripts list --event-ids 406161 `--direction asc|desc` is accepted by every list endpoint, but it reverses insertion order, not date order. +## Type ids + +`--type-ids` takes the ids from Quartr's own lookup tables. Both are returned whole — they are bounded catalogs (46 document types, 34 event types) and the default page size used to cut them off at 10, which is why ids like 25 and 46 looked undocumented: + +```bash +quartr document-types list --format csv +quartr event-types list --format csv +``` + +The ones you will reach for most, as of this writing: + +| Document type | id | Event type | id | +|---|---|---|---| +| Annual report (10-K) | 11 | Q1 earnings call | 26 | +| Quarterly report (10-Q) | 7 | Q2 earnings call | 27 | +| Earnings release (8-K) | 10 | Q3 earnings call | 28 | +| Annual report (20-F) | 13 | Q4 earnings call | 29 | +| Slides | 5 | H1 / H2 earnings call | 35 / 36 | +| Transcript | 15 | Capital Markets Day | 2 | +| Shareholder letter | 25 | Annual General Meeting | 4 | +| Proxy statement (DEF 14A) | 39 | Investor Day | 31 | +| Proxy statement (DEFM14A) | 46 | Guidance / update | 8 | + +Treat the table as a convenience: Quartr adds types over time, so the lookup commands above are the source of truth. + +## Tier-restricted endpoints + +Some endpoints are gated by API plan and answer with a bare `403 Forbidden`, which reads exactly like a credentials failure — especially since everything else keeps working with the same key. The CLI now says which one it is: + +```text +quartr api error: 403 Forbidden: {"message":"Forbidden","statusCode":403} +hint: 403 means this endpoint is not included in your API tier, not that your key is wrong +(a rejected key returns 401). ... +``` + +Endpoints observed gated this way: `events summary`, `audio list`, `live transcripts list`. A rejected key returns `401` and gets a hint pointing at `quartr auth show` instead. + ## Downloads Download commands first retrieve metadata, then download the URL field from the response. diff --git a/internal/cli/app.go b/internal/cli/app.go index 7522f3d..a7a09fc 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -67,6 +67,9 @@ func Run(args []string, out, errOut io.Writer) int { return 0 } fmt.Fprintln(errOut, err) + if hint := errorHint(err); hint != "" { + fmt.Fprintln(errOut, hint) + } var usage *usageError if errors.As(err, &usage) { return 2 diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index bb3ba9a..eae758f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -458,6 +458,100 @@ func TestDownloadStatusLineGoesToStderr(t *testing.T) { } } +func TestForbiddenIsExplainedAsTierNotAuth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden","statusCode":403}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "events", "summary", "406161"}, &out, &errOut) + if code != 1 { + t.Fatalf("expected code 1, got %d", code) + } + if !strings.Contains(errOut.String(), "not included in your API tier") { + t.Fatalf("expected a tier explanation, got %s", errOut.String()) + } + if !strings.Contains(errOut.String(), "401") { + t.Fatalf("expected the 401 contrast that rules out a bad key, got %s", errOut.String()) + } +} + +func TestUnauthorizedPointsAtTheKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Unauthorized","statusCode":401}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "companies", "list"}, &out, &errOut) + if code != 1 { + t.Fatalf("expected code 1, got %d", code) + } + if !strings.Contains(errOut.String(), "quartr auth show") { + t.Fatalf("expected the key-checking hint, got %s", errOut.String()) + } +} + +func TestLookupTablesReturnTheWholeCatalog(t *testing.T) { + pages := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("cursor") == "" { + if got := r.URL.Query().Get("limit"); got != "500" { + t.Errorf("expected the catalog to be fetched 500 at a time, got limit=%q", got) + } + _, _ = w.Write([]byte(`{"data":[{"id":11,"name":"Annual report","form":"10-K"}],"pagination":{"nextCursor":"p2"}}`)) + return + } + _, _ = w.Write([]byte(`{"data":[{"id":25,"name":"Shareholder letter","form":""}],"pagination":{"nextCursor":null}}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "document-types", "list"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if pages != 2 { + t.Fatalf("expected the catalog to be paged to exhaustion, got %d requests", pages) + } + // typeId 25 lives past the old default page of 10, which is why it read + // as undocumented. + if !strings.Contains(out.String(), "Shareholder letter") { + t.Fatalf("expected the tail of the catalog, got %s", out.String()) + } +} + +func TestExplicitLimitStillPagesTheCatalog(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if got := r.URL.Query().Get("limit"); got != "5" { + t.Errorf("expected limit=5 to be honored, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":11,"name":"Annual report"}],"pagination":{"nextCursor":"p2"}}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "document-types", "list", "--limit", "5"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if requests != 1 { + t.Fatalf("expected an explicit --limit to opt out of the full catalog, got %d requests", requests) + } +} + func TestListAllFollowsPagination(t *testing.T) { requests := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 41bee75..8334fe4 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -1,6 +1,12 @@ package cli -import "fmt" +import ( + "errors" + "fmt" + "net/http" + + "quartr-cli/internal/quartr" +) // usageError marks a failure caused by the command line the user typed // rather than by the API or the network. Run maps it to exit code 2 so @@ -13,3 +19,28 @@ func (e *usageError) Error() string { return e.msg } func usagef(format string, args ...any) error { return &usageError{msg: fmt.Sprintf(format, args...)} } + +// errorHint returns an extra line to print under an API error, for the two +// statuses users reliably misread. +// +// Quartr answers a tier-gated endpoint with a bare {"message":"Forbidden"}, +// which is indistinguishable from a credentials problem — and since the rest +// of the CLI keeps working on the same key, the natural conclusion is that +// auth is broken. It is not: 403 is entitlement, 401 is authentication. +func errorHint(err error) string { + var apiErr *quartr.APIError + if !errors.As(err, &apiErr) { + return "" + } + switch apiErr.StatusCode { + case http.StatusForbidden: + return "hint: 403 means this endpoint is not included in your API tier, not that your key is wrong " + + "(a rejected key returns 401). Every other endpoint keeps working with the same key. " + + "Endpoints seen gated this way: `events summary`, `audio list`, `live transcripts list`." + case http.StatusUnauthorized: + return "hint: 401 means the API key was rejected. Check `quartr auth show`, QUARTR_API_KEY, " + + "and any --api-key flag." + default: + return "" + } +} diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index 72e99f9..d7fe436 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -138,6 +138,11 @@ func (a *app) listResource(r resource, args []string) error { if err := a.applyQualifiedTickers(context.Background(), r, &lf); err != nil { return err } + // A lookup table is only useful whole: the type ids people need most + // (25 = shareholder letter, 46 = DEFM14A) live past the default page. + if r.fullCatalog && !flagWasPassed(args, "limit") && !flagWasPassed(args, "cursor") { + lf.all = true + } if lf.all && !flagWasPassed(args, "limit") { lf.limit = 500 } diff --git a/internal/cli/help.go b/internal/cli/help.go index cd19e4f..ca8d479 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -113,6 +113,14 @@ Common list flags: strings.ReplaceAll(sortRecipe(r), "\n", "\n ")) } } + if r.fullCatalog { + fmt.Fprintf(a.out, ` +Catalog: + `+"`quartr %s list`"+` returns the whole table by default: it is a lookup + list, and the ids people need most sit past the first page. Pass --limit + to page through it instead. +`, r.name) + } if r.downloadField != "" { fmt.Fprintf(a.out, ` Downloads: diff --git a/internal/cli/resources.go b/internal/cli/resources.go index 12d1cc7..6cae964 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -51,6 +51,10 @@ type resource struct { // means the endpoint has no sortBy parameter at all, which the CLI // reports instead of dropping the flag on the floor. sortFields paramSet + // fullCatalog marks a bounded lookup table that is only useful whole. + // Those endpoints page like any other, so the default limit of 10 turns + // a 46-row catalog into a 10-row one with nothing to say it was cut. + fullCatalog bool } var ( @@ -175,16 +179,18 @@ var resources = map[string]resource{ getParams: getLiveParams, }, "event-types": { - name: "event-types", - listPath: "/event-types", - getPath: "/event-types/{id}", - listParams: simpleListParams, + name: "event-types", + listPath: "/event-types", + getPath: "/event-types/{id}", + listParams: simpleListParams, + fullCatalog: true, }, "document-types": { - name: "document-types", - listPath: "/document-types", - getPath: "/document-types/{id}", - listParams: simpleListParams, + name: "document-types", + listPath: "/document-types", + getPath: "/document-types/{id}", + listParams: simpleListParams, + fullCatalog: true, }, }