Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ func registerBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cache[[]

if err := makeBooking(taskCtx, auth, locationName, dateString, cacheManager); err != nil {
log.Printf("Booking failed for date %q at %q: %v", dateString, locationName, err)
if errors.Is(err, ErrWeWorkRateLimited) {
http.Error(w, err.Error(), http.StatusTooManyRequests)
return
}
if errors.Is(err, ErrDateInOlderThanOneMonthFuture) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
Expand Down Expand Up @@ -78,9 +82,10 @@ type batchBookRequest struct {
}

type batchBookResult struct {
Date string `json:"date"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Date string `json:"date"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
RateLimited bool `json:"-"`
}

type batchBookResponse struct {
Expand Down Expand Up @@ -123,6 +128,10 @@ func registerBatchBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cac
bearerToken, weworkLocation, err := prepareBooking(taskCtx, auth, payload.Wework, cacheManager)
if err != nil {
log.Printf("Preparing batch booking failed at %q: %v", payload.Wework, err)
if errors.Is(err, ErrWeWorkRateLimited) {
http.Error(w, err.Error(), http.StatusTooManyRequests)
return
}
if errors.Is(err, ErrWeWorkLocationNotFound) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
Expand All @@ -136,7 +145,9 @@ func registerBatchBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cac
response := newBatchBookResponse(payload.Wework, results)

w.Header().Set("Content-Type", "application/json; charset=utf-8")
if hasBatchBookingError(results) {
if hasBatchBookingRateLimit(results) {
w.WriteHeader(http.StatusTooManyRequests)
} else if hasBatchBookingError(results) {
w.WriteHeader(http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
Expand Down Expand Up @@ -286,6 +297,7 @@ func runBatchBookings(ctx context.Context, token string, location WeWorkLocation
if err := makeBookingRequestFunc(ctx, token, parsedDates[i], location); err != nil {
results[i].Status = "error"
results[i].Error = err.Error()
results[i].RateLimited = errors.Is(err, ErrWeWorkRateLimited)
log.Printf("Batch booking failed for date %q at %q: %v", dates[i], location.Location.Name, err)
}
}(i)
Expand All @@ -305,6 +317,16 @@ func hasBatchBookingError(results []batchBookResult) bool {
return false
}

func hasBatchBookingRateLimit(results []batchBookResult) bool {
for _, result := range results {
if result.RateLimited {
return true
}
}

return false
}

// reformatDate validates the date string against the format "Feb 18, 2025"
func reformatDate(date string) (string, error) {
const layout = "Jan 2, 2006"
Expand Down
7 changes: 7 additions & 0 deletions api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ func TestNewBatchBookResponseSummarizesFullFailure(t *testing.T) {
}
}

func TestHasBatchBookingRateLimit(t *testing.T) {
results := []batchBookResult{{Date: "Mar 1, 2026", Status: "error", RateLimited: true}}
if !hasBatchBookingRateLimit(results) {
t.Fatal("Expected rate-limited batch result")
}
}

func TestRunBatchBookingsLimitsConcurrency(t *testing.T) {
original := makeBookingRequestFunc
defer func() { makeBookingRequestFunc = original }()
Expand Down
28 changes: 21 additions & 7 deletions weworkrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
)

var ErrWeWorkLocationNotFound = errors.New("wework location not found")
var ErrWeWorkRateLimited = errors.New("wework rate limit exceeded")

type WeWorkLocation struct {
Reservable struct {
Expand Down Expand Up @@ -56,7 +57,7 @@ type WeWorkProperty struct {
}

func FetchWeWorkLocation(ctx context.Context, token string, locationID string) (WeWorkLocation, error) {
request := resty.New().R().SetContext(ctx).SetAuthToken(token)
request := newWeWorkAPIRequest(ctx, token)

var locationsResponse WeWorkLocationsResponse

Expand Down Expand Up @@ -95,7 +96,7 @@ func FetchWeWorkLocationByName(ctx context.Context, token string, locationName s
}

func fetchWeWorkProperties(ctx context.Context, token string) ([]WeWorkProperty, error) {
request := resty.New().R().SetContext(ctx).SetAuthToken(token)
request := newWeWorkAPIRequest(ctx, token)

var properties []WeWorkProperty

Expand Down Expand Up @@ -338,11 +339,7 @@ func calculateUTCTime(date time.Time, localTime string, tzOffset string) (string
}

func makeBookingRequest(ctx context.Context, token string, date time.Time, space WeWorkLocation) error {
request := resty.New().R()

request.SetAuthToken(token)

request.SetContext(ctx)
request := newWeWorkAPIRequest(ctx, token)

// Calculate UTC times based on local times and timezone offset
// Local start time is 06:00, end time is 23:59
Expand Down Expand Up @@ -417,13 +414,30 @@ func weWorkRequestError(operation string, response *resty.Response, requestErr e
}

details := truncateForLog(response.String(), 2000)
if isWeWorkRateLimited(response) {
return fmt.Errorf("%w while %s: status=%s response=%q", ErrWeWorkRateLimited, operation, response.Status(), details)
}
if requestErr != nil {
return fmt.Errorf("%s: %w (status=%s response=%q)", operation, requestErr, response.Status(), details)
}

return fmt.Errorf("%s: status=%s response=%q", operation, response.Status(), details)
}

func isWeWorkRateLimited(response *resty.Response) bool {
return response != nil && response.StatusCode() == http.StatusTooManyRequests
}

func newWeWorkAPIRequest(ctx context.Context, token string) *resty.Request {
return resty.New().R().
SetContext(ctx).
SetAuthToken(token).
SetHeader("Accept", "application/json").
SetHeader("Origin", "https://members.wework.com").
SetHeader("Referer", "https://members.wework.com/workplaceone/content2/bookings/desks").
SetHeader("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36")
}

func truncateForLog(value string, limit int) string {
value = strings.TrimSpace(value)
if len(value) <= limit {
Expand Down
26 changes: 26 additions & 0 deletions weworkrequests_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package main

import (
"context"
"encoding/json"
"net/http"
"testing"
"time"

"resty.dev/v3"
)

func TestParseTimezoneOffset(t *testing.T) {
Expand Down Expand Up @@ -243,3 +247,25 @@ func TestTruncateForLog(t *testing.T) {
t.Fatalf("Unexpected truncated value: %q", got)
}
}

func TestIsWeWorkRateLimited(t *testing.T) {
response := &resty.Response{RawResponse: &http.Response{
StatusCode: http.StatusTooManyRequests,
Status: "429 Too Many Requests",
}}

if !isWeWorkRateLimited(response) {
t.Fatal("Expected rate-limited response")
}
}

func TestNewWeWorkAPIRequestUsesBrowserHeaders(t *testing.T) {
request := newWeWorkAPIRequest(context.Background(), "token")

if request.Header.Get("Origin") != "https://members.wework.com" {
t.Fatalf("Unexpected Origin header: %q", request.Header.Get("Origin"))
}
if request.Header.Get("Referer") == "" || request.Header.Get("User-Agent") == "" {
t.Fatal("Expected browser Referer and User-Agent headers")
}
}
Loading